diff --git a/skills b/skills index 2275afb46..87d75f69b 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 2275afb4668ef99d5e6ac4246d4a1d150605444f +Subproject commit 87d75f69b7e600ab9fc5cee9d861546e01c89fac diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 916ea0429..d904c577e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -13,12 +13,10 @@ dependencies = [ "chrono", "cron", "dirs 5.0.1", - "encoding_rs", "env_logger", "futures", "futures-util", "keyring", - "llama-cpp-2", "log", "once_cell", "parking_lot", @@ -423,26 +421,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.10.0", - "cexpr", - "clang-sys", - "itertools", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex", - "syn 2.0.114", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -650,15 +628,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] - [[package]] name = "cfb" version = "0.7.3" @@ -716,26 +685,6 @@ dependencies = [ "inout", ] -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading 0.8.9", -] - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -1233,12 +1182,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "embed-resource" version = "3.0.6" @@ -1409,15 +1352,6 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" -[[package]] -name = "find_cuda_helper" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f9e65c593dd01ac77daad909ea4ad17f0d6d1776193fc8ea766356177abdad" -dependencies = [ - "glob", -] - [[package]] name = "flate2" version = "1.1.8" @@ -2370,15 +2304,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.17" @@ -2555,7 +2480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading 0.7.4", + "libloading", "once_cell", ] @@ -2575,16 +2500,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - [[package]] name = "libredox" version = "0.1.12" @@ -2618,34 +2533,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "llama-cpp-2" -version = "0.1.133" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "888c8805527f4c35ec16f26003d54a318cde1629e7439da8e9ef2d6d3883e106" -dependencies = [ - "encoding_rs", - "enumflags2", - "llama-cpp-sys-2", - "thiserror 1.0.69", - "tracing", - "tracing-core", -] - -[[package]] -name = "llama-cpp-sys-2" -version = "0.1.133" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a180dfa6d6f9d1df1e031bcdf0464bbad4f9b326395bfd28f2fa539d8cbc9c2b" -dependencies = [ - "bindgen", - "cc", - "cmake", - "find_cuda_helper", - "glob", - "walkdir", -] - [[package]] name = "lock_api" version = "0.4.14" @@ -3642,16 +3529,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.114", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -5588,7 +5465,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", ] [[package]] @@ -5786,12 +5662,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "vcpkg" version = "0.2.15" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3e5fd8025..bbd2195c1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -93,12 +93,6 @@ rquickjs = { version = "0.11", features = ["futures", "macro", "loader", "parall # to ensure macOS 10.15 deployment target compatibility. tdlib-rs = { version = "1.2", features = ["download-tdlib"] } -# Local LLM inference - desktop only (llama.cpp requires native C++ compilation) -# Disable default features (including openmp) to avoid cross-compilation issues -# on macOS where Homebrew installs ARM64-only OpenMP libraries -llama-cpp-2 = { version = "0.1", default-features = false } -encoding_rs = "0.8" - [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.build-dependencies] # TDLib build configuration (download-tdlib for all desktop platforms) tdlib-rs = { version = "1.2", features = ["download-tdlib"] } diff --git a/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MediaPipeLlmBridge.kt b/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MediaPipeLlmBridge.kt deleted file mode 100644 index d66bb1d82..000000000 --- a/src-tauri/gen/android/app/src/main/java/com/alphahuman/app/MediaPipeLlmBridge.kt +++ /dev/null @@ -1,308 +0,0 @@ -package com.alphahuman.app - -import android.content.Context -import android.util.Log -import com.google.mediapipe.tasks.genai.llminference.LlmInference -import com.google.mediapipe.tasks.genai.llminference.LlmInference.LlmInferenceOptions -import org.json.JSONObject -import java.io.File -import java.util.concurrent.atomic.AtomicBoolean - -/** - * MediaPipe LLM Inference Bridge for Android - * - * Provides a JNI-accessible interface to MediaPipe's LLM Inference API for the Rust backend. - * Enables on-device LLM inference using Google's MediaPipe framework. - * - * Supported models: Gemma 3n, Gemma 2, Phi-2, Falcon, StableLM - * See: https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android - */ -object MediaPipeLlmBridge { - private const val TAG = "MediaPipeLlmBridge" - - // LLM Inference instance (singleton) - private var llmInference: LlmInference? = null - - // Application context reference - private var appContext: Context? = null - - // Current model path - private var currentModelPath: String? = null - - // Loading state - private val isLoading = AtomicBoolean(false) - - // Streaming callback - private var streamingCallback: ((String, Boolean) -> Unit)? = null - - /** - * Initialize the bridge with application context. - * Must be called from MainActivity before using other methods. - */ - @JvmStatic - fun initialize(context: Context) { - appContext = context.applicationContext - Log.i(TAG, "MediaPipe LLM Bridge initialized") - } - - /** - * Check if MediaPipe LLM is available on this device. - * @return JSON with availability status and device info - */ - @JvmStatic - fun isAvailable(): String { - return try { - val json = JSONObject() - json.put("available", true) - json.put("initialized", llmInference != null) - json.put("model_loaded", currentModelPath != null) - json.put("current_model", currentModelPath ?: "") - json.toString() - } catch (e: Exception) { - Log.e(TAG, "Error checking availability", e) - """{"available":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" - } - } - - /** - * Load a model from the specified path. - * @param modelPath Path to the .task model file (e.g., /data/local/tmp/llm/gemma-3-1b-it-int4.task) - * @param maxTokens Maximum number of tokens to generate (default: 1024) - * @param topK Top-K sampling parameter (default: 40) - * @param temperature Sampling temperature (default: 0.8) - * @param randomSeed Random seed for reproducibility (default: 0 = random) - * @return JSON with success status or error - */ - @JvmStatic - fun loadModel( - modelPath: String, - maxTokens: Int = 1024, - topK: Int = 40, - temperature: Float = 0.8f, - randomSeed: Int = 0 - ): String { - val context = appContext - if (context == null) { - return """{"success":false,"error":"Bridge not initialized. Call initialize() first."}""" - } - - if (isLoading.get()) { - return """{"success":false,"error":"Model is already loading"}""" - } - - return try { - isLoading.set(true) - Log.i(TAG, "Loading model from: $modelPath") - - // Check if model file exists - val modelFile = File(modelPath) - if (!modelFile.exists()) { - isLoading.set(false) - return """{"success":false,"error":"Model file not found: $modelPath"}""" - } - - // Close existing model if any - llmInference?.close() - llmInference = null - currentModelPath = null - - // Build options - // Note: MediaPipe LLM Inference API 0.10.x only supports: - // setModelPath, setMaxTokens, setMaxTopK - // Temperature and randomSeed are not available in this API version - val optionsBuilder = LlmInferenceOptions.builder() - .setModelPath(modelPath) - .setMaxTokens(maxTokens) - .setMaxTopK(topK) - - // Temperature and randomSeed parameters are accepted but not used - @Suppress("UNUSED_VARIABLE") - val unusedParams = listOf(temperature, randomSeed) - - val options = optionsBuilder.build() - - // Create LLM inference instance - llmInference = LlmInference.createFromOptions(context, options) - currentModelPath = modelPath - - isLoading.set(false) - Log.i(TAG, "Model loaded successfully") - - val json = JSONObject() - json.put("success", true) - json.put("model_path", modelPath) - json.toString() - } catch (e: Exception) { - isLoading.set(false) - Log.e(TAG, "Error loading model", e) - """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" - } - } - - /** - * Generate a response synchronously. - * @param prompt The input prompt - * @return JSON with generated text or error - */ - @JvmStatic - fun generateResponse(prompt: String): String { - val inference = llmInference - if (inference == null) { - return """{"success":false,"error":"No model loaded. Call loadModel() first."}""" - } - - return try { - Log.d(TAG, "Generating response for prompt: ${prompt.take(100)}...") - - val response = inference.generateResponse(prompt) - - val json = JSONObject() - json.put("success", true) - json.put("response", response) - json.put("prompt", prompt) - json.toString() - } catch (e: Exception) { - Log.e(TAG, "Error generating response", e) - """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" - } - } - - /** - * Generate a response asynchronously with streaming. - * Results are sent via the streaming callback. - * @param prompt The input prompt - * @return JSON with status - */ - @JvmStatic - fun generateResponseAsync(prompt: String): String { - val inference = llmInference - if (inference == null) { - return """{"success":false,"error":"No model loaded. Call loadModel() first."}""" - } - - return try { - Log.d(TAG, "Starting async generation for prompt: ${prompt.take(100)}...") - - inference.generateResponseAsync(prompt) { partialResult, done -> - streamingCallback?.invoke(partialResult, done) - } - - val json = JSONObject() - json.put("success", true) - json.put("status", "streaming") - json.toString() - } catch (e: Exception) { - Log.e(TAG, "Error starting async generation", e) - """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" - } - } - - /** - * Set the streaming callback for async generation. - * @param callback Function that receives (partialResult: String, isDone: Boolean) - */ - @JvmStatic - fun setStreamingCallback(callback: (String, Boolean) -> Unit) { - streamingCallback = callback - } - - /** - * Unload the current model and free resources. - * @return JSON with status - */ - @JvmStatic - fun unloadModel(): String { - return try { - llmInference?.close() - llmInference = null - currentModelPath = null - - Log.i(TAG, "Model unloaded") - """{"success":true}""" - } catch (e: Exception) { - Log.e(TAG, "Error unloading model", e) - """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" - } - } - - /** - * Get the default model storage directory. - * @return Path to the models directory - */ - @JvmStatic - fun getModelsDirectory(): String { - val context = appContext ?: return "/data/local/tmp/llm" - - // Use app's files directory for model storage - val modelsDir = File(context.filesDir, "models") - if (!modelsDir.exists()) { - modelsDir.mkdirs() - } - return modelsDir.absolutePath - } - - /** - * List available models in the models directory. - * @return JSON array of model files - */ - @JvmStatic - fun listModels(): String { - return try { - val modelsDir = File(getModelsDirectory()) - val models = modelsDir.listFiles { file -> - file.isFile && (file.name.endsWith(".task") || file.name.endsWith(".bin")) - } ?: emptyArray() - - val json = JSONObject() - json.put("success", true) - json.put("models_dir", modelsDir.absolutePath) - - val modelsList = models.map { file -> - JSONObject().apply { - put("name", file.name) - put("path", file.absolutePath) - put("size_mb", file.length() / (1024 * 1024)) - } - } - json.put("models", modelsList) - json.toString() - } catch (e: Exception) { - Log.e(TAG, "Error listing models", e) - """{"success":false,"error":"${e.message?.replace("\"", "\\\"")}"}""" - } - } - - /** - * Get recommended models for download. - * @return JSON with model recommendations and download URLs - */ - @JvmStatic - fun getRecommendedModels(): String { - val json = JSONObject() - json.put("success", true) - json.put("models", listOf( - JSONObject().apply { - put("name", "Gemma 3 1B (4-bit)") - put("id", "gemma-3-1b-it-int4") - put("size_mb", 550) - put("description", "Compact, fast model suitable for most devices") - put("url", "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/main/gemma3-1b-it-int4.task") - }, - JSONObject().apply { - put("name", "Gemma 3n E2B (4-bit)") - put("id", "gemma-3n-e2b-it-int4") - put("size_mb", 1400) - put("description", "Effective 2B model with multimodal support") - put("url", "https://huggingface.co/litert-community/Gemma3n-E2B-IT/resolve/main/gemma3n-e2b-it-int4.task") - }, - JSONObject().apply { - put("name", "Gemma 3n E4B (4-bit)") - put("id", "gemma-3n-e4b-it-int4") - put("size_mb", 2800) - put("description", "Effective 4B model, best quality, requires high-end device") - put("url", "https://huggingface.co/litert-community/Gemma3n-E4B-IT/resolve/main/gemma3n-e4b-it-int4.task") - } - )) - return json.toString() - } -} diff --git a/src-tauri/src/commands/model.rs b/src-tauri/src/commands/model.rs index 5ad806833..684c28348 100644 --- a/src-tauri/src/commands/model.rs +++ b/src-tauri/src/commands/model.rs @@ -1,386 +1,92 @@ //! Model Tauri Commands //! -//! These commands provide local LLM access via Tauri's invoke() system. -//! - Desktop (Windows, macOS, Linux): Uses llama.cpp via llama-cpp-2 crate -//! - Android: Uses MediaPipe LLM Inference API via JNI -//! - iOS: Not yet supported +//! Thin proxy commands that forward AI summarization/generation requests +//! to the cloud backend via reqwest. No local LLM is used. -use serde::{Deserialize, Serialize}; +use serde::Deserialize; -// serde_json used for Android MediaPipe commands -#[cfg(target_os = "android")] -use serde_json::{json, Value as JsonValue}; - -/// Model status response for frontend. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelStatusResponse { - /// Whether the model API is available on this platform. - pub available: bool, - /// Whether the model is currently loaded in memory. - 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. - pub error: Option, - /// Model file path if known. - pub model_path: Option, +#[derive(Debug, Deserialize)] +struct SummarizeResponse { + summary: String, } -/// Generation configuration from frontend. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct GenerateRequest { - /// Input prompt. - pub prompt: String, - /// Maximum tokens to generate (default: 2048). - #[serde(default = "default_max_tokens")] - pub max_tokens: u32, - /// Sampling temperature (default: 0.7). - #[serde(default = "default_temperature")] - pub temperature: f32, - /// Top-p sampling (default: 0.9). - #[serde(default = "default_top_p")] - pub top_p: f32, +#[derive(Debug, Deserialize)] +struct GenerateResponse { + text: String, } -fn default_max_tokens() -> u32 { - 2048 -} -fn default_temperature() -> f32 { - 0.7 -} -fn default_top_p() -> f32 { - 0.9 -} - -// ============================================================================ -// Android JNI Bridge to MediaPipe LLM -// ============================================================================ - -#[cfg(target_os = "android")] -mod android { - use super::*; - - /// Call a static method on MediaPipeLlmBridge that returns a String. - /// This uses Android's JNI to communicate with the Kotlin MediaPipe wrapper. - pub fn call_mediapipe_method(method: &str) -> Result { - // For now, return a placeholder - actual JNI implementation requires - // access to the JNI environment which needs to be passed from Tauri's - // Android activity context. - // - // TODO: Implement proper JNI bridge using tauri's android module - // The MediaPipeLlmBridge Kotlin object is already set up and ready. - log::warn!( - "MediaPipe LLM method '{}' called - JNI bridge pending implementation", - method - ); - Err(format!( - "MediaPipe LLM JNI bridge not yet implemented for method: {}", - method - )) - } - - /// Call a static method on MediaPipeLlmBridge with a String argument. - pub fn call_mediapipe_method_with_arg(method: &str, arg: &str) -> Result { - log::warn!( - "MediaPipe LLM method '{}' called with arg - JNI bridge pending implementation", - method - ); - let _ = arg; - Err(format!( - "MediaPipe LLM JNI bridge not yet implemented for method: {}", - method - )) - } - - /// Parse a JSON response from MediaPipe bridge. - pub fn parse_mediapipe_response(json: &str) -> Result { - serde_json::from_str(json).map_err(|e| format!("Failed to parse MediaPipe response: {}", e)) - } -} - -/// Check if the local model API is available on this platform. -/// - Desktop: Uses llama.cpp (always available) -/// - Android: Uses MediaPipe LLM (available on supported devices) -/// - iOS: Not yet supported +/// Summarize text via the backend API. #[tauri::command] -pub fn model_is_available() -> bool { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - true +pub async fn model_summarize( + backend_url: String, + token: String, + text: String, + max_tokens: Option, +) -> Result { + let client = reqwest::Client::new(); + let mut body = serde_json::json!({ "text": text }); + if let Some(mt) = max_tokens { + body["maxTokens"] = serde_json::json!(mt); } - #[cfg(target_os = "android")] - { - // MediaPipe LLM is available on Android - true + let resp = client + .post(format!("{}/api/ai/summarize", backend_url)) + .header("Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + .await + .map_err(|e| format!("Request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + return Err(format!("Backend returned {status}: {body_text}")); } - #[cfg(target_os = "ios")] - { - false - } + let data: SummarizeResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + + Ok(data.summary) } -/// Get the current model status. +/// Generate text via the backend API. #[tauri::command] -pub fn model_get_status() -> ModelStatusResponse { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - let status = crate::services::llama::LLAMA_MANAGER.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, - } - } - - #[cfg(target_os = "android")] - { - // MediaPipe LLM status - JNI bridge pending full implementation - // For now, report available but not loaded - 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, - } - } - - #[cfg(target_os = "ios")] - { - ModelStatusResponse { - available: false, - loaded: false, - loading: false, - downloaded: false, - download_progress: None, - error: Some("Model not available on iOS".to_string()), - model_path: None, - } - } -} - -/// Ensure the model is loaded (downloads if necessary). -/// This is useful for preloading the model. -#[tauri::command] -pub async fn model_ensure_loaded() -> Result<(), String> { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - crate::services::llama::LLAMA_MANAGER.ensure_loaded().await - } - - #[cfg(target_os = "android")] - { - // MediaPipe requires manual model download - // TODO: Implement model download via MediaPipeLlmBridge.loadModel() - Err("MediaPipe LLM: Please download a model first using the model manager".to_string()) - } - - #[cfg(target_os = "ios")] - { - Err("Model not available on iOS".to_string()) - } -} - -/// Generate text from a prompt. -#[tauri::command] -pub async fn model_generate(request: GenerateRequest) -> Result { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - use crate::services::llama::GenerateConfig; - - let config = GenerateConfig { - max_tokens: request.max_tokens, - temperature: request.temperature, - top_p: request.top_p, - }; - - crate::services::llama::LLAMA_MANAGER - .generate(&request.prompt, config) - .await - } - - #[cfg(target_os = "android")] - { - // TODO: Call MediaPipeLlmBridge.generateResponse() via JNI - // The Kotlin bridge is ready, just needs JNI wiring - let _ = request; - Err("MediaPipe LLM generation pending JNI implementation".to_string()) - } - - #[cfg(target_os = "ios")] - { - let _ = request; - Err("Model not available on iOS".to_string()) - } -} - -/// Summarize text using a built-in prompt. -#[tauri::command] -pub async fn model_summarize(text: String, max_tokens: Option) -> Result { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - let tokens = max_tokens.unwrap_or(500); - crate::services::llama::LLAMA_MANAGER - .summarize(&text, tokens) - .await - } - - #[cfg(target_os = "android")] - { - // TODO: Implement summarization via MediaPipe - let _ = (text, max_tokens); - Err("MediaPipe LLM summarization pending JNI implementation".to_string()) - } - - #[cfg(target_os = "ios")] - { - let _ = (text, max_tokens); - Err("Model not available on iOS".to_string()) - } -} - -/// Start downloading the model file without loading into memory. -/// Safe to call from the Welcome page (pre-auth). Supports resume. -#[tauri::command] -pub async fn model_start_download() -> 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> { - #[cfg(not(any(target_os = "android", target_os = "ios")))] - { - crate::services::llama::LLAMA_MANAGER.unload(); - Ok(()) - } - - #[cfg(target_os = "android")] - { - // TODO: Call MediaPipeLlmBridge.unloadModel() via JNI - Ok(()) - } - - #[cfg(target_os = "ios")] - { - Err("Model not available on iOS".to_string()) - } -} - -// ============================================================================ -// Android-specific MediaPipe LLM Commands -// ============================================================================ - -/// Get recommended models for download (Android only). -/// Returns a list of MediaPipe-compatible models with download URLs. -#[tauri::command] -pub fn model_get_recommended() -> Result { - #[cfg(target_os = "android")] - { - // Return hardcoded recommended models for now - // TODO: Call MediaPipeLlmBridge.getRecommendedModels() via JNI - let models = serde_json::json!({ - "success": true, - "models": [ - { - "name": "Gemma 3 1B (4-bit)", - "id": "gemma-3-1b-it-int4", - "size_mb": 550, - "description": "Compact, fast model suitable for most devices", - "url": "https://huggingface.co/litert-community/Gemma3-1B-IT/resolve/main/gemma3-1b-it-int4.task" - }, - { - "name": "Gemma 3n E2B (4-bit)", - "id": "gemma-3n-e2b-it-int4", - "size_mb": 1400, - "description": "Effective 2B model with multimodal support", - "url": "https://huggingface.co/litert-community/Gemma3n-E2B-IT/resolve/main/gemma3n-e2b-it-int4.task" - }, - { - "name": "Gemma 3n E4B (4-bit)", - "id": "gemma-3n-e4b-it-int4", - "size_mb": 2800, - "description": "Effective 4B model, best quality, requires high-end device", - "url": "https://huggingface.co/litert-community/Gemma3n-E4B-IT/resolve/main/gemma3n-e4b-it-int4.task" - } - ] - }); - Ok(models.to_string()) - } - - #[cfg(not(target_os = "android"))] - { - Err("This command is only available on Android".to_string()) - } -} - -/// List downloaded models (Android only). -#[tauri::command] -pub fn model_list_downloaded() -> Result { - #[cfg(target_os = "android")] - { - // TODO: Call MediaPipeLlmBridge.listModels() via JNI - let result = serde_json::json!({ - "success": true, - "models": [], - "models_dir": "/data/data/com.alphahuman.app/files/models" - }); - Ok(result.to_string()) - } - - #[cfg(not(target_os = "android"))] - { - Err("This command is only available on Android".to_string()) - } -} - -/// Load a specific model by path (Android only). -#[tauri::command] -pub fn model_load_path( - model_path: String, - max_tokens: Option, - top_k: Option, +pub async fn model_generate( + backend_url: String, + token: String, + prompt: String, + max_tokens: Option, temperature: Option, ) -> Result { - #[cfg(target_os = "android")] - { - // TODO: Call MediaPipeLlmBridge.loadModel() via JNI - let _ = (model_path, max_tokens, top_k, temperature); - Err("MediaPipe model loading pending JNI implementation".to_string()) + let client = reqwest::Client::new(); + let mut body = serde_json::json!({ "prompt": prompt }); + if let Some(mt) = max_tokens { + body["maxTokens"] = serde_json::json!(mt); + } + if let Some(t) = temperature { + body["temperature"] = serde_json::json!(t); } - #[cfg(not(target_os = "android"))] - { - let _ = (model_path, max_tokens, top_k, temperature); - Err("This command is only available on Android".to_string()) + let resp = client + .post(format!("{}/api/ai/generate", backend_url)) + .header("Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + .await + .map_err(|e| format!("Request failed: {e}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + return Err(format!("Backend returned {status}: {body_text}")); } + + let data: GenerateResponse = resp + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + + Ok(data.text) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e5f8fcfb..b5076b7e2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -294,11 +294,6 @@ pub fn run() { }); let skills_data_dir = data_dir.join("skills"); - // Initialize local model service (for skills to use) - let model_dir = data_dir.join("models"); - services::llama::LLAMA_MANAGER.set_data_dir(model_dir); - log::info!("[runtime] Local model service initialized"); - match runtime::qjs_engine::RuntimeEngine::new(skills_data_dir) { Ok(engine) => { engine.set_app_handle(app.handle().clone()); @@ -338,12 +333,12 @@ pub fn run() { #[cfg(target_os = "android")] { - log::info!("[runtime] QuickJS runtime and local model disabled on Android"); + log::info!("[runtime] QuickJS runtime disabled on Android"); } #[cfg(target_os = "ios")] { - log::info!("[runtime] QuickJS runtime and local model disabled on iOS"); + log::info!("[runtime] QuickJS runtime disabled on iOS"); } // Store SocketManager as Tauri state @@ -457,18 +452,9 @@ pub fn run() { tdlib_receive, tdlib_destroy, tdlib_is_available, - // Model commands (local LLM) - model_is_available, - model_get_status, - model_ensure_loaded, - model_start_download, - model_generate, + // Model commands (backend API proxy) model_summarize, - model_unload, - // Android MediaPipe LLM commands - model_get_recommended, - model_list_downloaded, - model_load_path, + model_generate, ] } #[cfg(not(desktop))] @@ -550,18 +536,9 @@ pub fn run() { tdlib_receive, tdlib_destroy, tdlib_is_available, - // Model commands (local LLM / MediaPipe) - model_is_available, - model_get_status, - model_ensure_loaded, - model_start_download, - model_generate, + // Model commands (backend API proxy) model_summarize, - model_unload, - // Android MediaPipe LLM commands - model_get_recommended, - model_list_downloaded, - model_load_path, + model_generate, ] } }) diff --git a/src-tauri/src/services/llama/manager.rs b/src-tauri/src/services/llama/manager.rs deleted file mode 100644 index 720e45035..000000000 --- a/src-tauri/src/services/llama/manager.rs +++ /dev/null @@ -1,766 +0,0 @@ -//! LlamaManager — singleton manager for local LLM inference. -//! -//! 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 - -use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -use llama_cpp_2::context::params::LlamaContextParams; -use llama_cpp_2::llama_backend::LlamaBackend; -use llama_cpp_2::llama_batch::LlamaBatch; -use llama_cpp_2::model::params::LlamaModelParams; -use llama_cpp_2::model::LlamaModel; -use llama_cpp_2::sampling::LlamaSampler; -use llama_cpp_2::token::data_array::LlamaTokenDataArray; -use once_cell::sync::Lazy; -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; - -/// Global LLama manager instance. -pub static LLAMA_MANAGER: Lazy = Lazy::new(LlamaManager::new); - -/// Model file name (Gemma 3n E2B Q4_K_M quantization) -const MODEL_FILENAME: &str = "gemma-3n-E2B-it-Q4_K_M.gguf"; - -/// HuggingFace model URL for download -const MODEL_URL: &str = "https://huggingface.co/bartowski/google_gemma-3n-E2B-it-GGUF/resolve/main/google_gemma-3n-E2B-it-Q4_K_M.gguf"; - -/// 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")] -pub struct ModelStatus { - /// Whether the model API is available on this platform. - pub available: bool, - /// Whether the model is currently loaded in memory. - 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. - pub error: Option, - /// Model file path if known. - pub model_path: Option, -} - -impl Default for ModelStatus { - fn default() -> Self { - Self { - available: true, - loaded: false, - loading: false, - downloaded: false, - download_progress: None, - error: None, - model_path: None, - } - } -} - -/// Configuration for text generation. -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub struct GenerateConfig { - /// Maximum tokens to generate (default: 2048). - #[serde(default = "default_max_tokens")] - pub max_tokens: u32, - /// Sampling temperature (default: 0.7). - #[serde(default = "default_temperature")] - pub temperature: f32, - /// Top-p sampling (default: 0.9). - #[serde(default = "default_top_p")] - pub top_p: f32, -} - -fn default_max_tokens() -> u32 { - 2048 -} -fn default_temperature() -> f32 { - 0.7 -} -fn default_top_p() -> f32 { - 0.9 -} - -/// Internal state for the loaded model. -struct LoadedModel { - backend: LlamaBackend, - model: LlamaModel, -} - -// Safety: LlamaBackend and LlamaModel are thread-safe through their C API -unsafe impl Send for LoadedModel {} -unsafe impl Sync for LoadedModel {} - -/// LLama Manager for local model inference. -pub struct LlamaManager { - /// Directory for model files. - data_dir: RwLock, - /// Loaded model (lazy-loaded on first use). - model: RwLock>>, - /// Current status. - status: RwLock, - /// Lock to prevent concurrent loading. - loading: AtomicBool, - /// Lock to prevent concurrent downloads. - downloading: AtomicBool, -} - -impl LlamaManager { - /// Create a new LlamaManager (model not loaded yet). - pub fn new() -> Self { - Self { - data_dir: RwLock::new(PathBuf::new()), - model: RwLock::new(None), - status: RwLock::new(ModelStatus::default()), - loading: AtomicBool::new(false), - downloading: AtomicBool::new(false), - } - } - - /// Set the data directory for model storage. - pub fn set_data_dir(&self, dir: PathBuf) { - log::info!("[llama] Setting data dir: {:?}", dir); - *self.data_dir.write() = dir.clone(); - - // Update status with model path and downloaded flag - let model_path = dir.join(MODEL_FILENAME); - 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 { - let mut status = self.status.read().clone(); - // Dynamically check downloaded state - status.downloaded = self.model_exists(); - status - } - - /// Get the model file path. - fn model_path(&self) -> PathBuf { - 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 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() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create model directory: {}", e))?; - } - - // Check for existing partial download - let (mut existing_size, mut resume) = - self.check_resume_state(&download_path, &meta_path); - - let client = reqwest::Client::new(); - - // 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))?; - - 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(); - } - } - } - - // 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); - - 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(); - let mut downloaded = start_offset; - - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| format!("Download error: {}", e))?; - file.write_all(&chunk) - .map_err(|e| format!("Failed to write chunk: {}", e))?; - - downloaded += chunk.len() as u64; - - if total_size > 0 { - let progress = downloaded as f32 / total_size as f32; - self.status.write().download_progress = Some(progress); - - // Log progress every 10% - 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); - } - } - } - - // Flush and close file - file.flush() - .map_err(|e| format!("Failed to flush file: {}", e))?; - drop(file); - - // 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 - ); - - 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? - if self.model.read().is_some() { - return Ok(()); - } - - // Prevent concurrent loading - if self.loading.swap(true, Ordering::SeqCst) { - // Another thread is loading, wait for it - while self.loading.load(Ordering::SeqCst) { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - // Check if loading succeeded - if self.model.read().is_some() { - return Ok(()); - } - return Err("Model loading failed".to_string()); - } - - // Update status - { - let mut status = self.status.write(); - status.loading = true; - status.error = None; - } - - let result = self.load_model_internal().await; - - // Update status based on result - { - let mut status = self.status.write(); - status.loading = false; - match &result { - Ok(_) => { - status.loaded = true; - status.error = None; - } - Err(e) => { - status.loaded = false; - status.error = Some(e.clone()); - } - } - } - - self.loading.store(false, Ordering::SeqCst); - result - } - - /// Internal model loading logic. - async fn load_model_internal(&self) -> Result<(), String> { - // Check if model exists, download if not - if !self.model_exists() { - log::info!("[llama] Model not found, downloading..."); - self.download_model().await?; - } - - let model_path = self.model_path(); - log::info!("[llama] Loading model from {:?}", model_path); - - // Load model in blocking thread - let path = model_path.clone(); - let loaded = tokio::task::spawn_blocking(move || -> Result { - // Initialize llama backend - let backend = LlamaBackend::init() - .map_err(|e| format!("Failed to initialize llama backend: {}", e))?; - - // Set up model parameters - let model_params = LlamaModelParams::default(); - - // Load the model - let model = LlamaModel::load_from_file(&backend, &path, &model_params) - .map_err(|e| format!("Failed to load model: {}", e))?; - - Ok(LoadedModel { backend, model }) - }) - .await - .map_err(|e| format!("Task join error: {}", e))??; - - // Store the loaded model - *self.model.write() = Some(Arc::new(loaded)); - log::info!("[llama] Model loaded successfully"); - - Ok(()) - } - - /// Generate text from a prompt. - pub async fn generate(&self, prompt: &str, config: GenerateConfig) -> Result { - // Ensure model is loaded - self.ensure_loaded().await?; - - let model_arc = self - .model - .read() - .clone() - .ok_or_else(|| "Model not loaded".to_string())?; - - let prompt = prompt.to_string(); - let max_tokens = config.max_tokens; - let temperature = config.temperature; - - // Run inference in blocking thread - tokio::task::spawn_blocking(move || { - Self::generate_sync(&model_arc, &prompt, max_tokens, temperature) - }) - .await - .map_err(|e| format!("Task join error: {}", e))? - } - - /// Synchronous text generation (runs on blocking thread). - fn generate_sync( - loaded: &LoadedModel, - prompt: &str, - max_tokens: u32, - temperature: f32, - ) -> Result { - // Create context for inference - let ctx_params = LlamaContextParams::default() - .with_n_ctx(std::num::NonZeroU32::new(8192)); - - let mut ctx = loaded - .model - .new_context(&loaded.backend, ctx_params) - .map_err(|e| format!("Failed to create context: {}", e))?; - - // Tokenize the prompt - let tokens = loaded - .model - .str_to_token(prompt, llama_cpp_2::model::AddBos::Always) - .map_err(|e| format!("Failed to tokenize: {}", e))?; - - if tokens.is_empty() { - return Err("Empty prompt".to_string()); - } - - // Create batch with initial tokens - let mut batch = LlamaBatch::new(8192, 1); - - for (i, token) in tokens.iter().enumerate() { - let is_last = i == tokens.len() - 1; - batch - .add(*token, i as i32, &[0], is_last) - .map_err(|e| format!("Failed to add token to batch: {}", e))?; - } - - // Decode initial tokens - ctx.decode(&mut batch) - .map_err(|e| format!("Failed to decode batch: {}", e))?; - - // Generate tokens - let mut output_tokens = Vec::new(); - let mut n_cur = tokens.len(); - - // Create sampler chain for temperature sampling - let seed = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u32) - .unwrap_or(42); - - for _ in 0..max_tokens { - // Get logits for the last token - let logits = ctx.candidates_ith(batch.n_tokens() - 1); - - // Create token data array for sampling - let mut candidates = LlamaTokenDataArray::from_iter(logits, false); - - // Apply temperature sampler - let mut temp_sampler = LlamaSampler::temp(temperature); - candidates.apply_sampler(&mut temp_sampler); - - // Sample token with random seed - let new_token = candidates.sample_token(seed); - - // Check for end of generation - if loaded.model.is_eog_token(new_token) { - break; - } - - output_tokens.push(new_token); - - // Prepare next batch - batch.clear(); - batch - .add(new_token, n_cur as i32, &[0], true) - .map_err(|e| format!("Failed to add token: {}", e))?; - - n_cur += 1; - - // Decode - ctx.decode(&mut batch) - .map_err(|e| format!("Failed to decode: {}", e))?; - } - - // Convert tokens to string using token_to_piece - let mut decoder = encoding_rs::UTF_8.new_decoder(); - let mut output = String::new(); - - for token in &output_tokens { - match loaded.model.token_to_piece(*token, &mut decoder, false, None) { - Ok(piece) => output.push_str(&piece), - Err(e) => { - log::warn!("[llama] Failed to decode token: {}", e); - } - } - } - - Ok(output) - } - - /// Summarize text using a built-in prompt. - pub async fn summarize(&self, text: &str, max_tokens: u32) -> Result { - let prompt = format!( - "user\nPlease provide a concise summary of the following text:\n\n{}\n\nmodel\n", - text - ); - - self.generate( - &prompt, - GenerateConfig { - max_tokens, - temperature: 0.5, // Lower temperature for more focused summarization - top_p: 0.9, - }, - ) - .await - } - - /// Unload the model from memory. - pub fn unload(&self) { - log::info!("[llama] Unloading model"); - *self.model.write() = None; - self.status.write().loaded = false; - } -} - -impl Default for LlamaManager { - fn default() -> Self { - Self::new() - } -} - -// Ensure LlamaManager is Send + Sync -unsafe impl Send for LlamaManager {} -unsafe impl Sync for LlamaManager {} diff --git a/src-tauri/src/services/llama/mod.rs b/src-tauri/src/services/llama/mod.rs deleted file mode 100644 index 436639f39..000000000 --- a/src-tauri/src/services/llama/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Local LLM Service using llama-cpp-2 -//! -//! Provides local AI model inference for skills using llama.cpp. -//! Desktop only - not available on Android/iOS. - -mod manager; - -pub use manager::GenerateConfig; -pub use manager::LlamaManager; -pub use manager::ModelStatus; -pub use manager::LLAMA_MANAGER; diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 790558885..7be2111d5 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -11,6 +11,3 @@ pub mod quickjs_libs; #[cfg(desktop)] pub mod notification_service; -// Local LLM inference - desktop only (llama.cpp requires native C++ compilation) -#[cfg(not(any(target_os = "android", target_os = "ios")))] -pub mod llama; diff --git a/src-tauri/src/services/quickjs-libs/bootstrap.js b/src-tauri/src/services/quickjs-libs/bootstrap.js index 3fab84e69..c79999696 100644 --- a/src-tauri/src/services/quickjs-libs/bootstrap.js +++ b/src-tauri/src/services/quickjs-libs/bootstrap.js @@ -979,75 +979,68 @@ globalThis.tdlib = { }; // ============================================================================ -// Model Bridge API (local LLM inference) +// Model Bridge API (routes to cloud backend) // ============================================================================ -globalThis.__model = { - isAvailable: function () { - try { - return typeof __ops?.model_is_available === 'function' - ? __ops.model_is_available() - : false; - } catch (e) { - return false; - } - }, - getStatus: function () { - return __ops.model_get_status(); - }, - generate: async function (prompt, configJson) { - return await __ops.model_generate(prompt, configJson); - }, - summarize: async function (text, maxTokens) { - return await __ops.model_summarize(text, maxTokens); - }, -}; - globalThis.model = { /** - * Check if local model is available (desktop only). - * @returns {boolean} - */ - isAvailable: function () { - return __model.isAvailable(); - }, - - /** - * Get model status. - * @returns {{ available: boolean, loaded: boolean, loading: boolean, downloadProgress?: number, error?: string, modelPath?: string }} - */ - getStatus: function () { - return __model.getStatus(); - }, - - /** - * Generate text from a prompt. + * Generate text from a prompt via the backend API. * @param {string} prompt - Input prompt * @param {object} [options] - Generation options * @param {number} [options.maxTokens=2048] - Max output tokens * @param {number} [options.temperature=0.7] - Sampling temperature - * @param {number} [options.topP=0.9] - Top-p sampling - * @returns {Promise} + * @returns {string} */ - generate: async function (prompt, options) { - var config = { - max_tokens: (options && options.maxTokens) || 2048, - temperature: (options && options.temperature) || 0.7, - top_p: (options && options.topP) || 0.9, - }; - return await __model.generate(prompt, config); + generate: function (prompt, options) { + var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; + var jwtToken = __platform.env('JWT_TOKEN') || ''; + var body = { prompt: prompt }; + if (options && options.maxTokens) body.maxTokens = options.maxTokens; + if (options && options.temperature) body.temperature = options.temperature; + var result = __net.fetch(backendUrl + '/api/ai/generate', JSON.stringify({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + jwtToken, + }, + body: JSON.stringify(body), + timeout: 30000, + })); + var parsed = JSON.parse(result); + if (parsed.status >= 400) { + throw new Error('Backend returned ' + parsed.status + ': ' + parsed.body); + } + var data = JSON.parse(parsed.body); + return data.text || ''; }, /** - * Summarize text locally. + * Summarize text via the backend API. * @param {string} text - Text to summarize * @param {object} [options] - Options * @param {number} [options.maxTokens=500] - Target summary length - * @returns {Promise} + * @returns {string} */ - summarize: async function (text, options) { - var maxTokens = (options && options.maxTokens) || 500; - return await __model.summarize(text, maxTokens); + summarize: function (text, options) { + var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; + var jwtToken = __platform.env('JWT_TOKEN') || ''; + var body = { text: text }; + if (options && options.maxTokens) body.maxTokens = options.maxTokens; + var result = __net.fetch(backendUrl + '/api/ai/summarize', JSON.stringify({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + jwtToken, + }, + body: JSON.stringify(body), + timeout: 30000, + })); + var parsed = JSON.parse(result); + if (parsed.status >= 400) { + throw new Error('Backend returned ' + parsed.status + ': ' + parsed.body); + } + var data = JSON.parse(parsed.body); + return data.summary || ''; }, }; diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/mod.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/mod.rs index 60edca54c..2acadac04 100644 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/mod.rs +++ b/src-tauri/src/services/quickjs-libs/qjs_ops/mod.rs @@ -7,10 +7,8 @@ //! - `ops_storage` — IndexedDB, DB bridge, Store bridge //! - `ops_state` — published state, filesystem data //! - `ops_tdlib` — TDLib (Telegram) integration -//! - `ops_model` — local LLM inference mod ops_core; -mod ops_model; mod ops_net; mod ops_state; mod ops_storage; @@ -44,7 +42,6 @@ pub fn register_ops( ops_storage::register(ctx, &ops, storage, skill_context.clone())?; ops_state::register(ctx, &ops, skill_state, skill_context.clone())?; ops_tdlib::register(ctx, &ops, skill_context.clone())?; - ops_model::register(ctx, &ops)?; globals.set("__ops", ops)?; Ok(()) diff --git a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_model.rs b/src-tauri/src/services/quickjs-libs/qjs_ops/ops_model.rs deleted file mode 100644 index 90b049844..000000000 --- a/src-tauri/src/services/quickjs-libs/qjs_ops/ops_model.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Model ops: local LLM inference via llama-cpp-2. - -use rquickjs::{function::Async, Ctx, Function, Object}; - -use super::types::js_err; - -pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>) -> rquickjs::Result<()> { - ops.set("model_is_available", Function::new(ctx.clone(), || -> bool { false }))?; - - ops.set("model_get_status", Function::new(ctx.clone(), || -> rquickjs::Result { - let status = crate::services::llama::LLAMA_MANAGER.get_status(); - serde_json::to_string(&status).map_err(|e| js_err(e.to_string())) - }))?; - - ops.set("model_generate", Function::new(ctx.clone(), - Async(move |prompt: String, config_json: String| async move { - let config: crate::services::llama::GenerateConfig = - serde_json::from_str(&config_json).map_err(|e| js_err(e.to_string()))?; - crate::services::llama::LLAMA_MANAGER - .generate(&prompt, config) - .await - .map_err(|e| js_err(e)) - }), - ))?; - - ops.set("model_summarize", Function::new(ctx.clone(), - Async(move |text: String, max_tokens: u32| async move { - crate::services::llama::LLAMA_MANAGER - .summarize(&text, max_tokens) - .await - .map_err(|e| js_err(e)) - }), - ))?; - - Ok(()) -} diff --git a/src/App.tsx b/src/App.tsx index 63751d9a5..0e6051d26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,40 +4,51 @@ import { HashRouter as Router } from 'react-router-dom'; import { PersistGate } from 'redux-persist/integration/react'; import AppRoutes from './AppRoutes'; +import ErrorFallbackScreen from './components/ErrorFallbackScreen'; +import MiniSidebar from './components/MiniSidebar'; 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'; +import { tagErrorSource } from './services/errorReportQueue'; import { persistor, store } from './store'; function App() { return ( - Something went wrong.}> + ( + + )} + onError={(_error, componentStack, eventId) => { + tagErrorSource(eventId, 'react', componentStack); + }}> - - - - - - -
-
-
- AlphaHuman is in early beta. + + + + + +
+
+ +
+
+ +
+
+
+ AlphaHuman is in early beta. Version 0.1.0 +
-
- -
- - - - - - +
+
+
+
+
+
diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 6cf59dc2a..e1d91eb79 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -4,10 +4,12 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import DefaultRedirect from './components/DefaultRedirect'; import ProtectedRoute from './components/ProtectedRoute'; import PublicRoute from './components/PublicRoute'; -import SettingsModal from './components/settings/SettingsModal'; +import Agents from './pages/Agents'; +import Conversations from './pages/Conversations'; import Home from './pages/Home'; import Login from './pages/Login'; import Onboarding from './pages/onboarding/Onboarding'; +import Settings from './pages/Settings'; import Welcome from './pages/Welcome'; import { selectIsOnboarded } from './store/authSelectors'; import { useAppSelector } from './store/hooks'; @@ -85,12 +87,32 @@ const AppRoutes = () => { } /> - {/* Settings modal routes - protected */} + {/* Conversations */} + + + + } + /> + + {/* Agents */} + + + + } + /> + + {/* Settings - rendered as page content */} - + } /> @@ -98,9 +120,6 @@ const AppRoutes = () => { {/* Default redirect based on auth status */} } /> - - {/* Settings Modal - renders over existing content when on settings routes */} - ); }; diff --git a/src/components/ErrorFallbackScreen.tsx b/src/components/ErrorFallbackScreen.tsx new file mode 100644 index 000000000..7f6cdcd42 --- /dev/null +++ b/src/components/ErrorFallbackScreen.tsx @@ -0,0 +1,92 @@ +/** + * ErrorFallbackScreen + * + * Full-screen recovery UI shown when the Sentry ErrorBoundary catches + * a catastrophic React render error. Self-contained with zero dependencies + * on Redux, Router, or any context provider. + * + * The ErrorReportNotification lives in a separate React root, so the user + * can still review and report the error from this screen. + */ + +interface ErrorFallbackScreenProps { + error: unknown; + componentStack?: string; + onReset: () => void; +} + +export default function ErrorFallbackScreen({ + error, + componentStack, + onReset, +}: ErrorFallbackScreenProps) { + const errorName = error instanceof Error ? error.name : 'Error'; + const errorMessage = error instanceof Error ? error.message : String(error); + + return ( +
+
+ {/* Accent bar */} +
+ +
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Title */} +

+ Something went wrong +

+

+ The application encountered an unexpected error and could not recover. +

+ + {/* Error details */} +
+

{errorName}

+

{errorMessage}

+ {componentStack && ( +
+ + Component stack + +
+                  {componentStack}
+                
+
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/src/components/ErrorReportNotification.tsx b/src/components/ErrorReportNotification.tsx new file mode 100644 index 000000000..2c82055dd --- /dev/null +++ b/src/components/ErrorReportNotification.tsx @@ -0,0 +1,223 @@ +/** + * ErrorReportNotification + * + * Non-blocking notification UI rendered via createPortal into its own React root. + * Subscribes to the error report queue and lets users inspect, dismiss, or + * report each error individually. + */ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; +import { createPortal } from 'react-dom'; + +import { isAnalyticsEnabled } from '../services/analytics'; +import { + dequeueError, + getErrors, + type PendingErrorReport, + sendToSentry, + subscribe, +} from '../services/errorReportQueue'; + +const MAX_VISIBLE = 3; +const AUTO_DISMISS_MS = 30_000; + +// --------------------------------------------------------------------------- +// Single notification card +// --------------------------------------------------------------------------- + +function NotificationCard({ + report, + onDismiss, +}: { + report: PendingErrorReport; + onDismiss: (id: string) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [sent, setSent] = useState(false); + const [exiting, setExiting] = useState(false); + const timerRef = useRef>(undefined); + const analyticsEnabled = isAnalyticsEnabled(); + const isDevOnly = !report.sentryEvent; + + const animateOut = useCallback( + (id: string) => { + setExiting(true); + setTimeout(() => onDismiss(id), 200); + }, + [onDismiss] + ); + + // Auto-dismiss timer + useEffect(() => { + timerRef.current = setTimeout(() => { + animateOut(report.id); + }, AUTO_DISMISS_MS); + return () => clearTimeout(timerRef.current); + }, [report.id, animateOut]); + + const handleDismiss = useCallback(() => { + clearTimeout(timerRef.current); + animateOut(report.id); + }, [report.id, animateOut]); + + const handleReport = useCallback(() => { + clearTimeout(timerRef.current); + const ok = sendToSentry(report); + if (ok) { + setSent(true); + setTimeout(() => onDismiss(report.id), 1200); + } + }, [report, onDismiss]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') handleDismiss(); + if (e.key === 'Enter') setExpanded(prev => !prev); + }, + [handleDismiss] + ); + + return ( +
+ {/* Header */} +
+ {/* Error icon */} +
+ +
+ +
+
+ {report.title} + {isDevOnly && ( + + DEV + + )} + {report.source === 'skill' && ( + + SKILL + + )} +
+

{report.message}

+
+ + {/* Close button */} + +
+ + {/* Expand toggle */} + {report.sentryEvent && ( + + )} + + {/* Expanded payload viewer */} + {expanded && report.sentryEvent && ( +
+
+            {JSON.stringify(report.sentryEvent, null, 2)}
+          
+
+ )} + + {/* Actions */} +
+ + + {sent ? ( + Sent + ) : isDevOnly ? ( + Console only + ) : !analyticsEnabled ? ( + + ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main notification container +// --------------------------------------------------------------------------- + +export default function ErrorReportNotification() { + const errors = useSyncExternalStore(subscribe, getErrors, getErrors); + + const handleDismiss = useCallback((id: string) => { + dequeueError(id); + }, []); + + // Escape key dismisses topmost notification + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && errors.length > 0) { + dequeueError(errors[errors.length - 1].id); + } + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [errors]); + + if (errors.length === 0) return null; + + const visible = errors.slice(-MAX_VISIBLE); + const hiddenCount = errors.length - MAX_VISIBLE; + + return createPortal( +
+ {visible.map(report => ( + + ))} + + {hiddenCount > 0 && ( +
+ +{hiddenCount} more {hiddenCount === 1 ? 'error' : 'errors'} +
+ )} +
, + document.body + ); +} diff --git a/src/components/MiniSidebar.tsx b/src/components/MiniSidebar.tsx new file mode 100644 index 000000000..85331d9ff --- /dev/null +++ b/src/components/MiniSidebar.tsx @@ -0,0 +1,117 @@ +import { useLocation, useNavigate } from 'react-router-dom'; + +import { useAppSelector } from '../store/hooks'; + +const navItems = [ + { + id: 'home', + label: 'Home', + path: '/home', + icon: ( + + + + ), + }, + // { + // id: 'conversations', + // label: 'Conversations', + // path: '/conversations', + // icon: ( + // + // + // + // ), + // }, + // { + // id: 'agents', + // label: 'Agents', + // path: '/agents', + // icon: ( + // + // + // + // ), + // }, + { + id: 'settings', + label: 'Settings', + path: '/settings', + icon: ( + + + + + ), + }, +]; + +const MiniSidebar = () => { + const location = useLocation(); + const navigate = useNavigate(); + const token = useAppSelector(state => state.auth.token); + + // Hide sidebar when not authenticated or on public/onboarding routes + const hiddenPaths = ['/', '/login', '/onboarding']; + if (!token || hiddenPaths.includes(location.pathname)) { + return null; + } + + const isActive = (path: string) => { + if (path === '/settings') return location.pathname.startsWith('/settings'); + return location.pathname === path; + }; + + return ( +
+ {navItems.map(item => { + const active = isActive(item.path); + return ( +
+ + {/* Tooltip - appears to the right */} +
+ {item.label} +
+
+ ); + })} +
+ ); +}; + +export default MiniSidebar; diff --git a/src/components/ModelDownloadProgress.tsx b/src/components/ModelDownloadProgress.tsx deleted file mode 100644 index 666948e34..000000000 --- a/src/components/ModelDownloadProgress.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { platform } from '@tauri-apps/plugin-os'; -import { useEffect, useState } from 'react'; - -import { useModelStatus } from '../hooks/useModelStatus'; - -interface ModelDownloadProgressProps { - className?: string; - showWhenLoaded?: boolean; -} - -const ModelDownloadProgress = ({ - className = '', - showWhenLoaded = false, -}: ModelDownloadProgressProps) => { - const { isAvailable, isLoaded, isLoading, isDownloaded, downloadProgress, error, ensureLoaded } = - useModelStatus(); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - // Detect mobile platform - const detectMobile = async () => { - try { - const currentPlatform = await platform(); - setIsMobile(currentPlatform === 'android' || currentPlatform === 'ios'); - } catch { - // If we can't detect platform, assume desktop - setIsMobile(false); - } - }; - detectMobile(); - }, []); - - // Show mobile-only message on mobile platforms - if (isMobile) { - return ( -
-
-
- - - -
-
- Local AI Model -

Available on desktop only

-
-
-
- ); - } - - // Don't render if not available on this platform - if (!isAvailable) { - return null; - } - - // Hide when downloaded or loaded (nothing for the user to do) - if ((isDownloaded || isLoaded) && !showWhenLoaded && !isLoading) { - return null; - } - - // Format download progress percentage - const progressPercent = downloadProgress !== null ? Math.round(downloadProgress * 100) : 0; - - // Determine status display - const getStatusDisplay = () => { - if (error) { - return { - icon: ( - - - - ), - title: 'Model Error', - description: error, - color: 'coral', - }; - } - - if (isLoading && downloadProgress !== null) { - return { - icon: ( - - - - ), - title: 'Downloading Local AI Model', - description: `${progressPercent}% complete (~1.2 GB).`, - color: 'primary', - }; - } - - if (isLoading) { - return { - icon: ( - - - - ), - title: 'Loading Local AI Model', - description: 'Initializing local inference engine...', - color: 'primary', - }; - } - - if (isLoaded) { - return { - icon: ( - - - - ), - title: 'AI Model Ready', - description: 'Local inference available', - color: 'sage', - }; - } - - // Not loaded, show download prompt - return { - icon: ( - - - - ), - title: 'Local AI Model', - description: 'Download for offline summarization', - color: 'stone', - }; - }; - - const statusDisplay = getStatusDisplay(); - - return ( -
-
- {/* Icon */} -
{statusDisplay.icon}
- - {/* Content */} -
-
- {statusDisplay.title} - {!isLoaded && !isLoading && !error && ( - - )} - {error && ( - - )} -
-

{statusDisplay.description}

- - {/* Progress bar */} - {isLoading && downloadProgress !== null && ( -
-
-
- )} -
-
-
- ); -}; - -export default ModelDownloadProgress; diff --git a/src/components/settings/SettingsHome.tsx b/src/components/settings/SettingsHome.tsx index 6b9cbffa6..2ede7815e 100644 --- a/src/components/settings/SettingsHome.tsx +++ b/src/components/settings/SettingsHome.tsx @@ -196,10 +196,10 @@ const SettingsHome = () => { ]; return ( -
+
-
+
{/* Main Settings */}
diff --git a/src/components/settings/SettingsLayout.tsx b/src/components/settings/SettingsLayout.tsx deleted file mode 100644 index aba1b19b8..000000000 --- a/src/components/settings/SettingsLayout.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { ReactNode, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; - -interface SettingsLayoutProps { - children: ReactNode; - onClose: () => void; -} - -const SettingsLayout = ({ children, onClose }: SettingsLayoutProps) => { - const modalRef = useRef(null); - - // Handle escape key - useEffect(() => { - const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onClose(); - } - }; - - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [onClose]); - - // Handle backdrop click - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onClose(); - } - }; - - // Focus management for accessibility - useEffect(() => { - const previousFocus = document.activeElement as HTMLElement; - - // Focus the modal container - if (modalRef.current) { - modalRef.current.focus(); - } - - // Restore focus when modal closes - return () => { - if (previousFocus && previousFocus.focus) { - previousFocus.focus(); - } - }; - }, []); - - const modalContent = ( -
-
e.stopPropagation()}> - {children} -
-
- ); - - return createPortal(modalContent, document.body); -}; - -export default SettingsLayout; diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx deleted file mode 100644 index 4248d0d00..000000000 --- a/src/components/settings/SettingsModal.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Route, Routes, useLocation } from 'react-router-dom'; - -import { useSettingsNavigation } from './hooks/useSettingsNavigation'; -import AdvancedPanel from './panels/AdvancedPanel'; -import BillingPanel from './panels/BillingPanel'; -import ConnectionsPanel from './panels/ConnectionsPanel'; -import MessagingPanel from './panels/MessagingPanel'; -import PrivacyPanel from './panels/PrivacyPanel'; -import ProfilePanel from './panels/ProfilePanel'; -import TeamInvitesPanel from './panels/TeamInvitesPanel'; -import TeamMembersPanel from './panels/TeamMembersPanel'; -import TeamPanel from './panels/TeamPanel'; -import SettingsHome from './SettingsHome'; -import SettingsLayout from './SettingsLayout'; - -const SettingsModal = () => { - const location = useLocation(); - const { closeSettings } = useSettingsNavigation(); - - // Only render modal when on settings routes - const isSettingsRoute = location.pathname.startsWith('/settings'); - - if (!isSettingsRoute) { - return null; - } - - return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - ); -}; - -export default SettingsModal; diff --git a/src/components/settings/components/SettingsHeader.tsx b/src/components/settings/components/SettingsHeader.tsx index c9e025520..5bb1fbb6b 100644 --- a/src/components/settings/components/SettingsHeader.tsx +++ b/src/components/settings/components/SettingsHeader.tsx @@ -1,5 +1,3 @@ -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; - interface SettingsHeaderProps { className?: string; title?: string; @@ -13,53 +11,32 @@ const SettingsHeader = ({ showBackButton = false, onBack, }: SettingsHeaderProps) => { - const { closeSettings } = useSettingsNavigation(); - return ( -
-
-
- {/* Back button */} - {showBackButton && onBack && ( - - )} +
+
+ {/* Back button */} + {showBackButton && onBack && ( + + )} - {/* Title */} -

- {title} -

-
- - {/* Close button */} - + {/* Title */} +

{title}

); diff --git a/src/components/settings/panels/BillingPanel.tsx b/src/components/settings/panels/BillingPanel.tsx index 2bedbae71..58b1da445 100644 --- a/src/components/settings/panels/BillingPanel.tsx +++ b/src/components/settings/panels/BillingPanel.tsx @@ -225,152 +225,137 @@ const BillingPanel = () => {
- {/* ── Plan tier cards ───────────────────────────────────── */} -
- {PLANS.map(plan => { - const isCurrent = plan.tier === currentTier; - const isUpgrade = checkIsUpgrade(plan.tier, currentTier); - const savings = annualSavings(plan, billingInterval); - const isThisPurchasing = isPurchasing && purchasingTier === plan.tier; +
+ {/* ── Plan tier cards ───────────────────────────────────── */} +
+ {PLANS.map(plan => { + const isCurrent = plan.tier === currentTier; + const isUpgrade = checkIsUpgrade(plan.tier, currentTier); + const savings = annualSavings(plan, billingInterval); + const isThisPurchasing = isPurchasing && purchasingTier === plan.tier; - return ( -
-
-
-
-

{plan.name}

- {/* Features inline with title */} - {plan.features.map(f => ( - - - {f.text} + return ( +
+
+
+
+

{plan.name}

+ {/* Features inline with title */} + {plan.features.map(f => ( + + + {f.text} + + ))} + {isCurrent && ( + + Current + + )} + {savings && ( + + Save {savings}% + + )} +
+
+ + {displayPrice(plan, billingInterval)} - ))} - {isCurrent && ( - - Current - - )} - {savings && ( - - Save {savings}% - - )} -
-
- - {displayPrice(plan, billingInterval)} - - {plan.tier !== 'FREE' && ( - /mo - )} - {plan.tier !== 'FREE' && billingInterval === 'annual' && ( - - (billed ${plan.annualPrice}/yr) - - )} + {plan.tier !== 'FREE' && ( + /mo + )} + {plan.tier !== 'FREE' && billingInterval === 'annual' && ( + + (billed ${plan.annualPrice}/yr) + + )} +
+ + {/* Action button */} + {isUpgrade && ( + + )}
- - {/* Action button */} - {isUpgrade && ( - - )}
-
- ); - })} -
- - {/* ── Purchasing overlay message ────────────────────────── */} - {isPurchasing && ( -
-
- - - - -

- Waiting for payment confirmation... Complete checkout in the browser window that - opened. -

-
+ ); + })}
- )} - {/* ── Pay with crypto toggle ────────────────────────────── */} -
-
-

Pay with Crypto

-

- You can choose to pay annually using crypto -

-
- -
- - {/* ── Upgrade benefits ───────────────────────────────────── */} -
-
-

Why upgrade?

-
    -
  • + {/* ── Purchasing overlay message ────────────────────────── */} + {isPurchasing && ( +
    +
    + - Unlock higher daily limits for more AI interactions -
  • - {currentTier === 'FREE' && ( +

    + Waiting for payment confirmation... Complete checkout in the browser window that + opened. +

    +
+
+ )} + + {/* ── Pay with crypto toggle ────────────────────────────── */} +
+
+

Pay with Crypto

+

+ You can choose to pay annually using crypto +

+
+ +
+ + {/* ── Upgrade benefits ───────────────────────────────────── */} +
+
+

Why upgrade?

+
  • { d="M5 13l4 4L19 7" /> - - Save up to 20% with annual plans and never worry about hitting limits - + Unlock higher daily limits for more AI interactions
  • - )} -
+ {currentTier === 'FREE' && ( +
  • + + + + + Save up to 20% with annual plans and never worry about hitting limits + +
  • + )} + +
    diff --git a/src/hooks/useModelStatus.ts b/src/hooks/useModelStatus.ts deleted file mode 100644 index 2d2bfb4e5..000000000 --- a/src/hooks/useModelStatus.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; -import { useCallback } from 'react'; - -import { useAppDispatch, useAppSelector } from '../store/hooks'; -import { - type ModelStatus, - setDownloadTriggered, - setModelError, - setModelLoading, - setModelStatus, -} from '../store/modelSlice'; - -/** - * Hook to read model status from Redux and provide control actions. - * Status polling and auto-download are handled by ModelProvider. - */ -export const useModelStatus = () => { - const dispatch = useAppDispatch(); - const model = useAppSelector(state => state.model); - - const fetchStatus = useCallback(async () => { - try { - const result = await invoke('model_get_status'); - dispatch(setModelStatus(result)); - return result; - } catch (error) { - console.error('[useModelStatus] Failed to fetch status:', error); - dispatch(setModelError(error instanceof Error ? error.message : 'Failed to fetch status')); - return null; - } - }, [dispatch]); - - const startDownload = useCallback(async () => { - try { - dispatch(setModelLoading(true)); - dispatch(setModelError(null)); - dispatch(setDownloadTriggered(true)); - await invoke('model_start_download'); - await fetchStatus(); - } catch (error) { - console.error('[useModelStatus] Failed to start download:', error); - dispatch(setModelError(error instanceof Error ? error.message : 'Failed to download model')); - } - }, [dispatch, fetchStatus]); - - const ensureLoaded = useCallback(async () => { - try { - dispatch(setModelLoading(true)); - dispatch(setModelError(null)); - await invoke('model_ensure_loaded'); - await fetchStatus(); - } catch (error) { - console.error('[useModelStatus] Failed to load model:', error); - dispatch(setModelError(error instanceof Error ? error.message : 'Failed to load model')); - } - }, [dispatch, fetchStatus]); - - const unload = useCallback(async () => { - try { - await invoke('model_unload'); - await fetchStatus(); - } catch (error) { - console.error('[useModelStatus] Failed to unload model:', error); - } - }, [fetchStatus]); - - return { - 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/main.tsx b/src/main.tsx index 30ea01b06..df5fa60e0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; +import ErrorReportNotification from './components/ErrorReportNotification'; import './index.css'; import './polyfills'; import { initSentry } from './services/analytics'; @@ -21,3 +22,9 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ); + +// Mount error notification in an isolated React root so it survives App crashes +const errorRoot = document.createElement('div'); +errorRoot.id = 'error-report-root'; +document.body.appendChild(errorRoot); +ReactDOM.createRoot(errorRoot).render(); diff --git a/src/pages/Agents.tsx b/src/pages/Agents.tsx new file mode 100644 index 000000000..ef4c7b532 --- /dev/null +++ b/src/pages/Agents.tsx @@ -0,0 +1,32 @@ +const Agents = () => { + return ( +
    +
    +
    +
    +
    +
    + + + +
    +

    Agents

    +

    Your AI agents will appear here

    +
    +
    +
    +
    +
    + ); +}; + +export default Agents; diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx new file mode 100644 index 000000000..49e813074 --- /dev/null +++ b/src/pages/Conversations.tsx @@ -0,0 +1,32 @@ +const Conversations = () => { + return ( +
    +
    +
    +
    +
    +
    + + + +
    +

    Conversations

    +

    Your conversations will appear here

    +
    +
    +
    +
    +
    + ); +}; + +export default Conversations; diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index a2bd44149..b088f1ad8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,7 +1,6 @@ import { useNavigate } from 'react-router-dom'; import ConnectionIndicator from '../components/ConnectionIndicator'; -import ModelDownloadProgress from '../components/ModelDownloadProgress'; import SkillsGrid from '../components/SkillsGrid'; import { useUser } from '../hooks/useUser'; import { TELEGRAM_BOT_USERNAME } from '../utils/config'; @@ -25,10 +24,6 @@ const Home = () => { await openUrl(`https://t.me/${TELEGRAM_BOT_USERNAME}`); }; - const handleManageConnections = () => { - navigate('/settings'); - }; - const handleUpgrade = () => { navigate('/settings/billing'); }; @@ -103,43 +98,8 @@ const Home = () => {
    - {/* Action buttons */} -
    - {/* Settings */} - -
    - {/* Skills Grid */} - -
    diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 000000000..e13e3a54c --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,33 @@ +import { Route, Routes } from 'react-router-dom'; + +import AdvancedPanel from '../components/settings/panels/AdvancedPanel'; +import BillingPanel from '../components/settings/panels/BillingPanel'; +import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel'; +import MessagingPanel from '../components/settings/panels/MessagingPanel'; +import PrivacyPanel from '../components/settings/panels/PrivacyPanel'; +import ProfilePanel from '../components/settings/panels/ProfilePanel'; +import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel'; +import TeamMembersPanel from '../components/settings/panels/TeamMembersPanel'; +import TeamPanel from '../components/settings/panels/TeamPanel'; +import SettingsHome from '../components/settings/SettingsHome'; + +const Settings = () => { + return ( +
    + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
    + ); +}; + +export default Settings; diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx index c4d1855b1..82341649b 100644 --- a/src/pages/Welcome.tsx +++ b/src/pages/Welcome.tsx @@ -1,9 +1,6 @@ -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; @@ -12,20 +9,6 @@ interface WelcomeProps { const Welcome = ({ isWeb }: WelcomeProps) => { const greetings = ['Hello HAL9000! 👋', "Let's cook! 🔥", 'The A-Team is here! 👊']; - 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 */} @@ -42,39 +25,6 @@ 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/providers/ModelProvider.tsx b/src/providers/ModelProvider.tsx deleted file mode 100644 index bd575ec8e..000000000 --- a/src/providers/ModelProvider.tsx +++ /dev/null @@ -1,136 +0,0 @@ -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 { - type ModelStatus, - setDownloadTriggered, - setModelError, - setModelLoading, - setModelStatus, -} 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 677c19ad5..f9c1f5f80 100644 --- a/src/providers/SkillProvider.tsx +++ b/src/providers/SkillProvider.tsx @@ -10,8 +10,9 @@ import { type ReactNode, useEffect, useRef } from 'react'; import { skillManager } from '../lib/skills/manager'; import type { SkillManifest } from '../lib/skills/types'; +import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue'; import { useAppDispatch, useAppSelector } from '../store/hooks'; -import { setSkillState } from '../store/skillsSlice'; +import { setSkillError, setSkillState } from '../store/skillsSlice'; import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config'; // --------------------------------------------------------------------------- @@ -62,18 +63,51 @@ export default function SkillProvider({ children }: { children: ReactNode }) { useEffect(() => { let unlisten: (() => void) | undefined; - listen<{ skillId: string; state: Record }>( - 'skill-state-changed', + listen<{ skillId: string; state: Record }>('skill-state-changed', event => { + const { skillId, state: newState } = event.payload; + dispatch(setSkillState({ skillId, state: newState })); + }) + .then(fn => { + unlisten = fn; + }) + .catch(err => { + console.error('[SkillProvider] Failed to listen for skill-state-changed:', err); + }); + + return () => { + unlisten?.(); + }; + }, [dispatch]); + + // Listen for skill runtime errors and surface them in the error notification + useEffect(() => { + let unlisten: (() => void) | undefined; + + listen<{ skill_id: string; status: string; error?: string; name?: string }>( + 'runtime:skill-status-changed', event => { - const { skillId, state: newState } = event.payload; - dispatch(setSkillState({ skillId, state: newState })); + const { skill_id, status, error, name } = event.payload; + if (status === 'error' && error) { + dispatch(setSkillError({ skillId: skill_id, error })); + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'skill', + title: `Skill Error: ${name ?? skill_id}`, + message: error, + sentryEvent: buildManualSentryEvent( + { type: 'SkillRuntimeError', value: error }, + { skill_id, ...(name ? { skill_name: name } : {}) } + ), + }); + } } ) .then(fn => { unlisten = fn; }) .catch(err => { - console.error('[SkillProvider] Failed to listen for skill-state-changed:', err); + console.error('[SkillProvider] Failed to listen for runtime:skill-status-changed:', err); }); return () => { diff --git a/src/services/analytics.ts b/src/services/analytics.ts index c361de1bb..eaed4eb99 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -13,10 +13,15 @@ * - User PII (IP address, cookies) * - Request bodies / headers * - Session replay + * + * Error flow: beforeSend intercepts all events, sanitizes them, queues them + * in the errorReportQueue for user opt-in, and returns null to prevent + * auto-sending. Users can then review and explicitly report each error. */ import * as Sentry from '@sentry/react'; import { store } from '../store'; +import { enqueueError, registerSentrySender, type SanitizedSentryEvent } from './errorReportQueue'; const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN as string | undefined; const IS_DEV = Boolean(import.meta.env.DEV) || import.meta.env.MODE === 'development'; @@ -25,6 +30,45 @@ const IS_DEV = Boolean(import.meta.env.DEV) || import.meta.env.MODE === 'develop // Helpers // --------------------------------------------------------------------------- +/** + * Strip sensitive fields from the exception object before including it + * in the sanitized event shown to the user and sent to Sentry. + * + * Removes: local variables (vars), source code lines (context_line, + * pre_context, post_context), mechanism.data, and module_metadata. + */ +function sanitizeException( + exception: Sentry.Event['exception'] +): SanitizedSentryEvent['exception'] { + if (!exception?.values) return undefined; + + return { + values: exception.values.map(entry => ({ + type: entry.type ?? 'Error', + value: entry.value ?? '', + stacktrace: entry.stacktrace?.frames + ? { + frames: entry.stacktrace.frames.map(frame => ({ + filename: frame.filename, + function: frame.function, + module: frame.module, + lineno: frame.lineno, + colno: frame.colno, + abs_path: frame.abs_path, + in_app: frame.in_app, + // Stripped: vars, context_line, pre_context, post_context, + // instruction_addr, addr_mode, debug_id, module_metadata + })), + } + : undefined, + mechanism: entry.mechanism + ? { type: entry.mechanism.type, handled: entry.mechanism.handled } + : undefined, + // Stripped: mechanism.data (arbitrary key-value pairs) + })), + }; +} + /** Check if the current user has opted into analytics. */ export function isAnalyticsEnabled(): boolean { const state = store.getState(); @@ -33,6 +77,12 @@ export function isAnalyticsEnabled(): boolean { return state.auth.isAnalyticsEnabledByUser[userId] !== false; } +// --------------------------------------------------------------------------- +// Bypass flag — when true, beforeSend passes the event through to Sentry +// --------------------------------------------------------------------------- + +let _bypassBeforeSend = false; + // --------------------------------------------------------------------------- // Sentry initialisation // --------------------------------------------------------------------------- @@ -71,10 +121,16 @@ export function initSentry(): void { sendDefaultPii: false, // ----------------------------------------------------------------------- - // Gate every event behind the user's analytics consent flag + // Intercept every event: sanitize, queue for user opt-in, block auto-send // ----------------------------------------------------------------------- beforeSend(event) { - if (!isAnalyticsEnabled()) return null; + // Bypass mode: let the event through (used by sendEventToSentry) + if (_bypassBeforeSend) { + _bypassBeforeSend = false; + return event; + } + + // --- Sanitize the event --- // Strip any breadcrumbs that somehow snuck in event.breadcrumbs = []; @@ -95,7 +151,35 @@ export function initSentry(): void { device: event.contexts?.device, }; - return event; + // --- Build a sanitized snapshot for the user to inspect --- + const sanitized: SanitizedSentryEvent = { + event_id: event.event_id ?? crypto.randomUUID().replace(/-/g, ''), + timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now() / 1000, + platform: event.platform ?? 'javascript', + exception: sanitizeException(event.exception), + contexts: event.contexts as SanitizedSentryEvent['contexts'], + user: event.user as SanitizedSentryEvent['user'], + tags: event.tags as Record | undefined, + environment: IS_DEV ? 'development' : 'production', + }; + + // Extract human-readable title + message from the exception + const firstException = event.exception?.values?.[0]; + const title = firstException?.type ?? 'Error'; + const message = firstException?.value ?? 'Unknown error'; + + // Queue the error for the notification UI + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'global', + title, + message, + sentryEvent: sanitized, + }); + + // Return null to prevent Sentry from auto-sending + return null; }, beforeSendTransaction() { @@ -106,6 +190,12 @@ export function initSentry(): void { // Ignore common non-actionable errors ignoreErrors: ['ResizeObserver loop', 'Network request failed', 'Load failed', 'AbortError'], }); + + // Register the bypass sender so the error queue can actually send events + registerSentrySender((sanitizedEvent: SanitizedSentryEvent) => { + _bypassBeforeSend = true; + Sentry.captureEvent(sanitizedEvent as unknown as Sentry.Event); + }); } // --------------------------------------------------------------------------- diff --git a/src/services/errorReportQueue.ts b/src/services/errorReportQueue.ts new file mode 100644 index 000000000..47cb39c83 --- /dev/null +++ b/src/services/errorReportQueue.ts @@ -0,0 +1,237 @@ +/** + * Error Report Queue + * + * Module-level error queue with zero React/Redux/Sentry dependencies. + * Captures errors from all sources (React, global JS, skill runtime) and + * lets the notification UI subscribe to display them for user opt-in reporting. + */ +import * as Sentry from '@sentry/react'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A stack frame with sensitive fields (vars, source context) stripped. */ +interface SafeStackFrame { + filename?: string; + function?: string; + module?: string; + lineno?: number; + colno?: number; + abs_path?: string; + in_app?: boolean; +} + +export interface SanitizedSentryEvent { + event_id: string; + timestamp: number; + platform: string; + exception?: { + values: Array<{ + type: string; + value: string; + stacktrace?: { frames?: SafeStackFrame[] }; + mechanism?: { type: string; handled?: boolean }; + }>; + }; + contexts?: { os?: object; browser?: object; device?: object }; + user?: { id: string }; + tags?: Record; + environment: string; +} + +export interface PendingErrorReport { + id: string; + timestamp: number; + source: 'react' | 'global' | 'skill' | 'manual'; + title: string; + message: string; + componentStack?: string; + sentryEvent: SanitizedSentryEvent | null; + originalError?: Error; +} + +// --------------------------------------------------------------------------- +// Internal state +// --------------------------------------------------------------------------- + +const MAX_QUEUE_SIZE = 10; + +let _queue: PendingErrorReport[] = []; +const _subscribers = new Set<() => void>(); + +// Dedup: track recent error messages to avoid duplicate notifications +const _recentErrors = new Map(); +const DEDUP_WINDOW_MS = 2000; + +function _notify(): void { + for (const cb of _subscribers) { + try { + cb(); + } catch { + // Subscriber error — silently ignore to prevent cascading failures + } + } +} + +function _dedupeKey(report: Pick): string { + return `${report.title}::${report.message}`; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Add an error report to the queue. Notifies all subscribers. */ +export function enqueueError(report: PendingErrorReport): void { + const key = _dedupeKey(report); + const now = Date.now(); + const lastSeen = _recentErrors.get(key); + if (lastSeen && now - lastSeen < DEDUP_WINDOW_MS) return; + _recentErrors.set(key, now); + + // Prune old dedup entries + if (_recentErrors.size > 50) { + for (const [k, t] of _recentErrors) { + if (now - t > DEDUP_WINDOW_MS) _recentErrors.delete(k); + } + } + + _queue = [..._queue, report]; + if (_queue.length > MAX_QUEUE_SIZE) { + _queue = _queue.slice(_queue.length - MAX_QUEUE_SIZE); + } + _notify(); +} + +/** Remove an error report by ID (after user acts on it). */ +export function dequeueError(id: string): void { + _queue = _queue.filter(r => r.id !== id); + _notify(); +} + +/** Return current queue snapshot. Compatible with useSyncExternalStore. */ +export function getErrors(): PendingErrorReport[] { + return _queue; +} + +/** Subscribe to queue changes. Returns unsubscribe function. */ +export function subscribe(cb: () => void): () => void { + _subscribers.add(cb); + return () => { + _subscribers.delete(cb); + }; +} + +/** + * Find a queued error by Sentry event ID and enrich it with React source info. + * Called from the ErrorBoundary's onError callback. + */ +export function tagErrorSource( + eventId: string | undefined, + source: PendingErrorReport['source'], + componentStack?: string +): void { + if (!eventId) return; + const idx = _queue.findIndex(r => r.sentryEvent?.event_id === eventId); + if (idx === -1) return; + + const updated = { + ..._queue[idx], + source, + componentStack: componentStack ?? _queue[idx].componentStack, + }; + _queue = [..._queue.slice(0, idx), updated, ..._queue.slice(idx + 1)]; + _notify(); +} + +// --------------------------------------------------------------------------- +// Sentry bypass — used by the notification to actually send a queued event +// --------------------------------------------------------------------------- + +/** Reference to the bypass sender set by analytics.ts during init. */ +let _sendViaSentry: ((event: SanitizedSentryEvent) => void) | null = null; + +export function registerSentrySender(fn: (event: SanitizedSentryEvent) => void): void { + _sendViaSentry = fn; +} + +/** Send a queued error's payload to Sentry and remove from queue. */ +export function sendToSentry(report: PendingErrorReport): boolean { + if (!report.sentryEvent || !_sendViaSentry) return false; + _sendViaSentry(report.sentryEvent); + dequeueError(report.id); + return true; +} + +// --------------------------------------------------------------------------- +// Sentry active check +// --------------------------------------------------------------------------- + +function isSentryActive(): boolean { + try { + const client = Sentry.getClient(); + return Boolean(client); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Build a SanitizedSentryEvent manually (for errors not from Sentry pipeline) +// --------------------------------------------------------------------------- + +export function buildManualSentryEvent( + error: { type: string; value: string }, + tags?: Record +): SanitizedSentryEvent { + return { + event_id: crypto.randomUUID().replace(/-/g, ''), + timestamp: Date.now() / 1000, + platform: 'javascript', + exception: { values: [{ type: error.type, value: error.value }] }, + tags, + environment: import.meta.env.DEV ? 'development' : 'production', + }; +} + +// --------------------------------------------------------------------------- +// Dev-mode global listeners +// --------------------------------------------------------------------------- + +function initGlobalListeners(): void { + window.addEventListener('error', (event: ErrorEvent) => { + // Skip if Sentry is active — it captures these via globalHandlersIntegration + if (isSentryActive()) return; + + const error = event.error instanceof Error ? event.error : new Error(event.message); + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'global', + title: error.name || 'Error', + message: error.message || event.message || 'Unknown error', + sentryEvent: null, + originalError: error, + }); + }); + + window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { + if (isSentryActive()) return; + + const reason = event.reason; + const error = reason instanceof Error ? reason : new Error(String(reason)); + enqueueError({ + id: crypto.randomUUID(), + timestamp: Date.now(), + source: 'global', + title: error.name || 'UnhandledRejection', + message: error.message || String(reason) || 'Unhandled promise rejection', + sentryEvent: null, + originalError: error, + }); + }); +} + +// Register listeners immediately on module load +initGlobalListeners(); diff --git a/src/store/index.ts b/src/store/index.ts index 859210ad7..970490ad4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -15,7 +15,6 @@ 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'; @@ -46,7 +45,6 @@ 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 deleted file mode 100644 index 46dc9dc53..000000000 --- a/src/store/modelSlice.ts +++ /dev/null @@ -1,60 +0,0 @@ -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;