Merge remote-tracking branch 'upstream/develop'

This commit is contained in:
cyrus
2026-02-09 23:46:12 +05:30
36 changed files with 1253 additions and 2647 deletions
+1 -1
Submodule skills updated: 2275afb466...87d75f69b7
+1 -131
View File
@@ -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"
-6
View File
@@ -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"] }
@@ -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()
}
}
+70 -364
View File
@@ -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<f32>,
/// Error message if loading failed.
pub error: Option<String>,
/// Model file path if known.
pub model_path: Option<String>,
#[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<String, String> {
// 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<String, String> {
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<JsonValue, String> {
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<u32>,
) -> Result<String, String> {
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<String, String> {
#[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<u32>) -> Result<String, String> {
#[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<String, String> {
#[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<String, String> {
#[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<i32>,
top_k: Option<i32>,
pub async fn model_generate(
backend_url: String,
token: String,
prompt: String,
max_tokens: Option<u32>,
temperature: Option<f32>,
) -> Result<String, String> {
#[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)
}
+6 -29
View File
@@ -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,
]
}
})
-766
View File
@@ -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<LlamaManager> = 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<f32>,
/// Error message if loading failed.
pub error: Option<String>,
/// Model file path if known.
pub model_path: Option<String>,
}
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<PathBuf>,
/// Loaded model (lazy-loaded on first use).
model: RwLock<Option<Arc<LoadedModel>>>,
/// Current status.
status: RwLock<ModelStatus>,
/// 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::<DownloadMeta>(&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::<u64>() {
// 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<u64> {
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<LoadedModel, String> {
// 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<String, String> {
// 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<String, String> {
// 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<String, String> {
let prompt = format!(
"<start_of_turn>user\nPlease provide a concise summary of the following text:\n\n{}\n<end_of_turn>\n<start_of_turn>model\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 {}
-11
View File
@@ -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;
-3
View File
@@ -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;
+46 -53
View File
@@ -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<string>}
* @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<string>}
* @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 || '';
},
};
@@ -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(())
@@ -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<String> {
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(())
}
+32 -21
View File
@@ -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 (
<Sentry.ErrorBoundary fallback={<div>Something went wrong.</div>}>
<Sentry.ErrorBoundary
fallback={({ error, componentStack, resetError }) => (
<ErrorFallbackScreen error={error} componentStack={componentStack} onReset={resetError} />
)}
onError={(_error, componentStack, eventId) => {
tagErrorSource(eventId, 'react', componentStack);
}}>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ModelProvider>
<UserProvider>
<SocketProvider>
<AIProvider>
<SkillProvider>
<Router>
<div className="relative h-screen flex flex-col overflow-hidden">
<div className="pointer-events-none flex-shrink-0 flex justify-center z-50">
<div className="bg-black w-full px-3 py-1.5 text-[11px] uppercase tracking-[0.18em] text-white/40 text-center">
AlphaHuman is in early beta.
<UserProvider>
<SocketProvider>
<AIProvider>
<SkillProvider>
<Router>
<div className="relative h-screen flex flex-col overflow-hidden">
<div className="flex-1 flex overflow-hidden">
<MiniSidebar />
<div className="flex flex-col flex-1 relative overflow-hidden">
<div className="flex-1 overflow-y-auto">
<AppRoutes />
</div>
<div className="pointer-events-none flex-shrink-0 flex justify-center z-50">
<div className="w-full px-3 py-1.5 text-[9px] uppercase tracking-[0.18em] text-white/40 text-center">
AlphaHuman is in early beta. Version 0.1.0
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<AppRoutes />
</div>
</div>
</Router>
</SkillProvider>
</AIProvider>
</SocketProvider>
</UserProvider>
</ModelProvider>
</div>
</Router>
</SkillProvider>
</AIProvider>
</SocketProvider>
</UserProvider>
</PersistGate>
</Provider>
</Sentry.ErrorBoundary>
+25 -6
View File
@@ -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 */}
<Route
path="/conversations"
element={
<ProtectedRoute requireAuth={true}>
<Conversations />
</ProtectedRoute>
}
/>
{/* Agents */}
<Route
path="/agents"
element={
<ProtectedRoute requireAuth={true}>
<Agents />
</ProtectedRoute>
}
/>
{/* Settings - rendered as page content */}
<Route
path="/settings/*"
element={
<ProtectedRoute requireAuth={true}>
<HomeRoute />
<Settings />
</ProtectedRoute>
}
/>
@@ -98,9 +120,6 @@ const AppRoutes = () => {
{/* Default redirect based on auth status */}
<Route path="*" element={<DefaultRedirect />} />
</Routes>
{/* Settings Modal - renders over existing content when on settings routes */}
<SettingsModal />
</>
);
};
+92
View File
@@ -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 (
<div className="fixed inset-0 flex items-center justify-center bg-gradient-to-b from-stone-950 to-stone-900">
<div className="w-full max-w-lg mx-4 bg-stone-900 border border-coral-500/30 rounded-2xl shadow-large overflow-hidden">
{/* Accent bar */}
<div className="h-1 bg-coral-500" />
<div className="p-8">
{/* Icon */}
<div className="flex justify-center mb-6">
<div className="w-16 h-16 rounded-full bg-coral-500/10 flex items-center justify-center">
<svg
className="w-8 h-8 text-coral-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
</div>
{/* Title */}
<h1 className="text-xl font-semibold text-white text-center mb-2">
Something went wrong
</h1>
<p className="text-sm text-stone-400 text-center mb-6">
The application encountered an unexpected error and could not recover.
</p>
{/* Error details */}
<div className="bg-stone-800/50 border border-stone-700/50 rounded-xl p-4 mb-6">
<p className="text-sm font-medium text-coral-400 mb-1">{errorName}</p>
<p className="text-xs text-stone-300 break-words">{errorMessage}</p>
{componentStack && (
<details className="mt-3">
<summary className="text-xs text-stone-500 cursor-pointer hover:text-stone-300 transition-colors">
Component stack
</summary>
<pre className="mt-2 text-[11px] text-stone-500 whitespace-pre-wrap break-words max-h-[200px] overflow-auto">
{componentStack}
</pre>
</details>
)}
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={onReset}
className="flex-1 bg-stone-700 hover:bg-stone-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
Try to Recover
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 bg-coral-500 hover:bg-coral-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
Reload App
</button>
</div>
</div>
</div>
</div>
);
}
+223
View File
@@ -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<ReturnType<typeof setTimeout>>(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 (
<div
role="alert"
aria-live="assertive"
tabIndex={0}
onKeyDown={handleKeyDown}
className={`w-[420px] bg-stone-900 border border-stone-700/50 rounded-2xl shadow-large overflow-hidden transition-all duration-200 ${
exiting ? 'opacity-0 translate-y-2' : 'animate-fade-up opacity-100'
}`}>
{/* Header */}
<div className="flex items-start gap-3 px-4 pt-4 pb-2">
{/* Error icon */}
<div className="flex-shrink-0 mt-0.5">
<svg
className="w-5 h-5 text-coral-500"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true">
<path
fillRule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.168 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white truncate">{report.title}</span>
{isDevOnly && (
<span className="flex-shrink-0 text-[10px] font-medium px-1.5 py-0.5 bg-amber-500/20 text-amber-400 rounded">
DEV
</span>
)}
{report.source === 'skill' && (
<span className="flex-shrink-0 text-[10px] font-medium px-1.5 py-0.5 bg-primary-500/20 text-primary-400 rounded">
SKILL
</span>
)}
</div>
<p className="text-xs text-stone-400 mt-0.5 line-clamp-2">{report.message}</p>
</div>
{/* Close button */}
<button
onClick={handleDismiss}
className="flex-shrink-0 p-1 text-stone-500 hover:text-stone-300 transition-colors rounded"
aria-label="Dismiss error notification">
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.28 3.22a.75.75 0 00-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 101.06 1.06L8 9.06l3.72 3.72a.75.75 0 101.06-1.06L9.06 8l3.72-3.72a.75.75 0 00-1.06-1.06L8 6.94 4.28 3.22z" />
</svg>
</button>
</div>
{/* Expand toggle */}
{report.sentryEvent && (
<button
onClick={() => setExpanded(prev => !prev)}
className="text-xs text-stone-500 hover:text-stone-300 transition-colors px-4 pb-2">
{expanded ? 'Hide details' : 'View details'}
</button>
)}
{/* Expanded payload viewer */}
{expanded && report.sentryEvent && (
<div className="px-4 pb-3">
<pre className="bg-stone-800/50 rounded-xl border border-stone-700/50 p-3 font-mono text-xs text-stone-300 max-h-[300px] overflow-auto whitespace-pre-wrap break-words">
{JSON.stringify(report.sentryEvent, null, 2)}
</pre>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 px-4 pb-4">
<button
onClick={handleDismiss}
className="bg-stone-700 hover:bg-stone-600 text-white text-xs rounded-lg px-3 py-1.5 transition-colors">
Dismiss
</button>
{sent ? (
<span className="text-xs text-sage-400 px-3 py-1.5">Sent</span>
) : isDevOnly ? (
<span className="text-xs text-stone-500 px-3 py-1.5">Console only</span>
) : !analyticsEnabled ? (
<button
disabled
className="bg-stone-700/50 text-stone-500 text-xs rounded-lg px-3 py-1.5 cursor-not-allowed"
title="Enable analytics in Settings to report errors">
Report
</button>
) : (
<button
onClick={handleReport}
className="bg-coral-500 hover:bg-coral-600 text-white text-xs rounded-lg px-3 py-1.5 transition-colors">
Report
</button>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// 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(
<div className="fixed bottom-4 right-4 z-[10000] flex flex-col-reverse gap-2 items-end">
{visible.map(report => (
<NotificationCard key={report.id} report={report} onDismiss={handleDismiss} />
))}
{hiddenCount > 0 && (
<div className="text-xs text-stone-400 bg-stone-800/80 border border-stone-700/50 rounded-lg px-3 py-1.5">
+{hiddenCount} more {hiddenCount === 1 ? 'error' : 'errors'}
</div>
)}
</div>,
document.body
);
}
+117
View File
@@ -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: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a2 2 0 01-2-2v-4a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2z"
/>
</svg>
),
},
// {
// id: 'conversations',
// label: 'Conversations',
// path: '/conversations',
// icon: (
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path
// strokeLinecap="round"
// strokeLinejoin="round"
// strokeWidth={2}
// d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
// />
// </svg>
// ),
// },
// {
// id: 'agents',
// label: 'Agents',
// path: '/agents',
// icon: (
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path
// strokeLinecap="round"
// strokeLinejoin="round"
// strokeWidth={2}
// d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
// />
// </svg>
// ),
// },
{
id: 'settings',
label: 'Settings',
path: '/settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
];
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 (
<div className="w-14 flex-shrink-0 bg-black backdrop-blur-md border-r border-white/10 flex flex-col items-center py-4 gap-2 z-50 relative">
{navItems.map(item => {
const active = isActive(item.path);
return (
<div key={item.id} className="relative group">
<button
onClick={() => navigate(item.path)}
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-200 cursor-pointer ${
active
? 'bg-white/10 text-black'
: 'text-stone-500 hover:text-stone-300 hover:bg-white/5'
}`}
aria-label={item.label}>
{item.icon}
</button>
{/* Tooltip - appears to the right */}
<div className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 bg-stone-800 text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{item.label}
</div>
</div>
);
})}
</div>
);
};
export default MiniSidebar;
-225
View File
@@ -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 (
<div className={`glass rounded-2xl p-3 shadow-large animate-fade-up ${className}`}>
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<svg
className="w-5 h-5 text-stone-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="font-medium text-sm">Local AI Model</span>
<p className="text-xs opacity-60">Available on desktop only</p>
</div>
</div>
</div>
);
}
// 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: (
<svg
className="w-5 h-5 text-coral-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
),
title: 'Model Error',
description: error,
color: 'coral',
};
}
if (isLoading && downloadProgress !== null) {
return {
icon: (
<svg
className="w-5 h-5 text-primary-500 animate-pulse"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
),
title: 'Downloading Local AI Model',
description: `${progressPercent}% complete (~1.2 GB).`,
color: 'primary',
};
}
if (isLoading) {
return {
icon: (
<svg
className="w-5 h-5 text-primary-500 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
),
title: 'Loading Local AI Model',
description: 'Initializing local inference engine...',
color: 'primary',
};
}
if (isLoaded) {
return {
icon: (
<svg
className="w-5 h-5 text-sage-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
title: 'AI Model Ready',
description: 'Local inference available',
color: 'sage',
};
}
// Not loaded, show download prompt
return {
icon: (
<svg
className="w-5 h-5 text-stone-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
),
title: 'Local AI Model',
description: 'Download for offline summarization',
color: 'stone',
};
};
const statusDisplay = getStatusDisplay();
return (
<div className={`glass rounded-2xl p-3 shadow-large animate-fade-up ${className}`}>
<div className="flex items-center gap-3">
{/* Icon */}
<div className="flex-shrink-0">{statusDisplay.icon}</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-sm">{statusDisplay.title}</span>
{!isLoaded && !isLoading && !error && (
<button
onClick={ensureLoaded}
className="text-xs text-primary-500 hover:text-primary-400 transition-colors">
Download
</button>
)}
{error && (
<button
onClick={ensureLoaded}
className="text-xs text-coral-500 hover:text-coral-400 transition-colors">
Retry
</button>
)}
</div>
<p className="text-xs opacity-60 truncate">{statusDisplay.description}</p>
{/* Progress bar */}
{isLoading && downloadProgress !== null && (
<div className="mt-2 w-full bg-stone-700/50 rounded-full h-1.5 overflow-hidden">
<div
className="bg-primary-500 h-full rounded-full transition-all duration-300 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
</div>
</div>
</div>
);
};
export default ModelDownloadProgress;
+2 -2
View File
@@ -196,10 +196,10 @@ const SettingsHome = () => {
];
return (
<div className="overflow-hidden h-full flex flex-col">
<div className="overflow-hidden h-full flex flex-col z-10 relative">
<SettingsHeader />
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto max-w-md mx-auto">
<div className="p-4 space-y-6">
{/* Main Settings */}
<div>
@@ -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<HTMLDivElement>(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 = (
<div
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-lg flex items-center justify-center p-4"
onClick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="settings-modal-title">
<div
ref={modalRef}
className="bg-stone-900 rounded-3xl shadow-large w-full max-w-[520px] max-h-[90vh] overflow-hidden animate-slide-right focus:outline-none focus:ring-0"
style={{
animationDuration: '300ms',
animationTimingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
animationFillMode: 'both',
}}
tabIndex={-1}
onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
);
return createPortal(modalContent, document.body);
};
export default SettingsLayout;
-45
View File
@@ -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 (
<SettingsLayout onClose={closeSettings}>
<Routes>
<Route path="/settings" element={<SettingsHome />} />
<Route path="/settings/connections" element={<ConnectionsPanel />} />
<Route path="/settings/messaging" element={<MessagingPanel />} />
<Route path="/settings/privacy" element={<PrivacyPanel />} />
<Route path="/settings/profile" element={<ProfilePanel />} />
<Route path="/settings/advanced" element={<AdvancedPanel />} />
<Route path="/settings/billing" element={<BillingPanel />} />
<Route path="/settings/team" element={<TeamPanel />} />
<Route path="/settings/team/members" element={<TeamMembersPanel />} />
<Route path="/settings/team/invites" element={<TeamInvitesPanel />} />
</Routes>
</SettingsLayout>
);
};
export default SettingsModal;
@@ -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 (
<div className={`bg-black/30 border-b border-stone-700 p-6 relative ${className}`}>
<div className="flex items-center justify-between">
<div className="flex items-center">
{/* Back button */}
{showBackButton && onBack && (
<button
onClick={onBack}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-stone-800/50 transition-colors mr-3"
aria-label="Go back">
<svg
className="w-5 h-5 opacity-70"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
)}
<div className={`bg-black/30 border-b border-stone-700 p-3 relative ${className}`}>
<div className="flex items-center">
{/* Back button */}
{showBackButton && onBack && (
<button
onClick={onBack}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-stone-800/50 transition-colors mr-3"
aria-label="Go back">
<svg
className="w-5 h-5 opacity-70"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
)}
{/* Title */}
<h2 className="text-lg font-semibold text-white" id="settings-modal-title">
{title}
</h2>
</div>
{/* Close button */}
<button
onClick={closeSettings}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-stone-800/50 transition-colors"
aria-label="Close settings">
<svg className="w-5 h-5 opacity-70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/* Title */}
<h2 className="text-lg font-semibold text-white">{title}</h2>
</div>
</div>
);
+140 -138
View File
@@ -225,152 +225,137 @@ const BillingPanel = () => {
</button>
</div>
{/* ── Plan tier cards ───────────────────────────────────── */}
<div className="space-y-2 px-4">
{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;
<div className="max-w-md mx-auto">
{/* ── Plan tier cards ───────────────────────────────────── */}
<div className="space-y-2 px-4">
{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 (
<div
key={plan.tier}
className={`rounded-2xl border p-3 transition-all ${
isCurrent
? 'border-primary-500/40 bg-primary-500/5'
: 'border-stone-700/50 bg-stone-800/40'
}`}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-semibold text-white">{plan.name}</h4>
{/* Features inline with title */}
{plan.features.map(f => (
<span key={f.text} className="text-xs text-stone-300">
<span className="text-stone-500 mx-1"></span>
{f.text}
return (
<div
key={plan.tier}
className={`rounded-2xl border p-3 transition-all ${
isCurrent
? 'border-primary-500/40 bg-primary-500/5'
: 'border-stone-700/50 bg-stone-800/40'
}`}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-semibold text-white">{plan.name}</h4>
{/* Features inline with title */}
{plan.features.map(f => (
<span key={f.text} className="text-xs text-stone-300">
<span className="text-stone-500 mx-1"></span>
{f.text}
</span>
))}
{isCurrent && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30">
Current
</span>
)}
{savings && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
Save {savings}%
</span>
)}
</div>
<div className="mt-0.5 flex items-baseline gap-1">
<span className="text-xl font-bold text-white">
{displayPrice(plan, billingInterval)}
</span>
))}
{isCurrent && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-primary-500/20 text-primary-400 border border-primary-500/30">
Current
</span>
)}
{savings && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-sage-500/20 text-sage-400 border border-sage-500/30">
Save {savings}%
</span>
)}
</div>
<div className="mt-0.5 flex items-baseline gap-1">
<span className="text-xl font-bold text-white">
{displayPrice(plan, billingInterval)}
</span>
{plan.tier !== 'FREE' && (
<span className="text-xs text-stone-400">/mo</span>
)}
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
<span className="text-xs text-stone-500 ml-1">
(billed ${plan.annualPrice}/yr)
</span>
)}
{plan.tier !== 'FREE' && (
<span className="text-xs text-stone-400">/mo</span>
)}
{plan.tier !== 'FREE' && billingInterval === 'annual' && (
<span className="text-xs text-stone-500 ml-1">
(billed ${plan.annualPrice}/yr)
</span>
)}
</div>
</div>
{/* Action button */}
{isUpgrade && (
<button
onClick={() => handleUpgrade(plan.tier)}
disabled={isPurchasing}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex-shrink-0 ${
isPurchasing
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
: 'bg-primary-500 hover:bg-primary-600 text-white'
}`}>
{isThisPurchasing ? 'Waiting...' : 'Upgrade'}
</button>
)}
</div>
{/* Action button */}
{isUpgrade && (
<button
onClick={() => handleUpgrade(plan.tier)}
disabled={isPurchasing}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex-shrink-0 ${
isPurchasing
? 'bg-stone-700/40 text-stone-500 cursor-not-allowed'
: 'bg-primary-500 hover:bg-primary-600 text-white'
}`}>
{isThisPurchasing ? 'Waiting...' : 'Upgrade'}
</button>
)}
</div>
</div>
);
})}
</div>
{/* ── Purchasing overlay message ────────────────────────── */}
{isPurchasing && (
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3 mx-4">
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-amber-400 animate-spin"
fill="none"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<p className="text-xs text-amber-300">
Waiting for payment confirmation... Complete checkout in the browser window that
opened.
</p>
</div>
);
})}
</div>
)}
{/* ── Pay with crypto toggle ────────────────────────────── */}
<div className="flex items-center justify-between rounded-xl bg-stone-800/40 border border-stone-700/40 p-3 mx-4">
<div>
<p className="text-xs font-medium text-white">Pay with Crypto</p>
<p className="text-[11px] text-stone-400 mt-0.5">
You can choose to pay annually using crypto
</p>
</div>
<button
onClick={() => setPaymentMethod(m => (m === 'card' ? 'crypto' : 'card'))}
className={`relative w-10 h-5 rounded-full transition-colors ${
paymentMethod === 'crypto' ? 'bg-primary-500' : 'bg-stone-600'
}`}
role="switch"
aria-checked={paymentMethod === 'crypto'}>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
paymentMethod === 'crypto' ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* ── Upgrade benefits ───────────────────────────────────── */}
<div className="px-4 pb-4 pt-2">
<div className="rounded-xl bg-gradient-to-br from-primary-500/10 to-sage-500/10 border border-primary-500/20 p-4">
<h3 className="text-sm font-semibold text-white mb-2">Why upgrade?</h3>
<ul className="space-y-1.5 text-xs text-stone-300">
<li className="flex items-start gap-2">
{/* ── Purchasing overlay message ────────────────────────── */}
{isPurchasing && (
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3 mx-4">
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-sage-400 flex-shrink-0 mt-0.5"
className="w-4 h-4 text-amber-400 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span>Unlock higher daily limits for more AI interactions</span>
</li>
{currentTier === 'FREE' && (
<p className="text-xs text-amber-300">
Waiting for payment confirmation... Complete checkout in the browser window that
opened.
</p>
</div>
</div>
)}
{/* ── Pay with crypto toggle ────────────────────────────── */}
<div className="flex items-center justify-between rounded-xl bg-stone-800/40 border border-stone-700/40 p-3 mx-4">
<div>
<p className="text-xs font-medium text-white">Pay with Crypto</p>
<p className="text-[11px] text-stone-400 mt-0.5">
You can choose to pay annually using crypto
</p>
</div>
<button
onClick={() => setPaymentMethod(m => (m === 'card' ? 'crypto' : 'card'))}
className={`relative w-10 h-5 rounded-full transition-colors ${
paymentMethod === 'crypto' ? 'bg-primary-500' : 'bg-stone-600'
}`}
role="switch"
aria-checked={paymentMethod === 'crypto'}>
<span
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
paymentMethod === 'crypto' ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* ── Upgrade benefits ───────────────────────────────────── */}
<div className="px-4 pb-4 pt-2">
<div className="rounded-xl bg-gradient-to-br from-primary-500/10 to-sage-500/10 border border-primary-500/20 p-4">
<h3 className="text-sm font-semibold text-white mb-2">Why upgrade?</h3>
<ul className="space-y-1.5 text-xs text-stone-300">
<li className="flex items-start gap-2">
<svg
className="w-4 h-4 text-sage-400 flex-shrink-0 mt-0.5"
@@ -384,12 +369,29 @@ const BillingPanel = () => {
d="M5 13l4 4L19 7"
/>
</svg>
<span>
Save up to 20% with annual plans and never worry about hitting limits
</span>
<span>Unlock higher daily limits for more AI interactions</span>
</li>
)}
</ul>
{currentTier === 'FREE' && (
<li className="flex items-start gap-2">
<svg
className="w-4 h-4 text-sage-400 flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span>
Save up to 20% with annual plans and never worry about hitting limits
</span>
</li>
)}
</ul>
</div>
</div>
</div>
</div>
-82
View File
@@ -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<ModelStatus>('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 };
+7
View File
@@ -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(
<App />
</React.StrictMode>
);
// 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(<ErrorReportNotification />);
+32
View File
@@ -0,0 +1,32 @@
const Agents = () => {
return (
<div className="min-h-full relative">
<div className="relative z-10 min-h-full flex flex-col">
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up text-center">
<div className="flex justify-center mb-4">
<svg
className="w-12 h-12 text-primary-400 opacity-60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</div>
<h1 className="text-xl font-bold mb-2">Agents</h1>
<p className="text-sm opacity-60">Your AI agents will appear here</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default Agents;
+32
View File
@@ -0,0 +1,32 @@
const Conversations = () => {
return (
<div className="min-h-full relative">
<div className="relative z-10 min-h-full flex flex-col">
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up text-center">
<div className="flex justify-center mb-4">
<svg
className="w-12 h-12 text-primary-400 opacity-60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<h1 className="text-xl font-bold mb-2">Conversations</h1>
<p className="text-sm opacity-60">Your conversations will appear here</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default Conversations;
-40
View File
@@ -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 = () => {
</button>
</div>
{/* Action buttons */}
<div className="glass rounded-3xl p-0 shadow-large animate-fade-up mt-4 overflow-hidden">
{/* Settings */}
<button
onClick={handleManageConnections}
className="w-full flex items-center justify-between p-3 bg-black/50 hover:bg-stone-800/30 transition-all duration-200 text-left rounded-3xl focus:outline-none">
<svg
className="w-5 h-5 opacity-60 flex-shrink-0 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<div className="flex-1">
<div className="font-medium text-sm mb-1">Settings</div>
<p className="opacity-70 text-xs">
Manage connections, privacy, profile, and app settings
</p>
</div>
</button>
</div>
{/* Skills Grid */}
<SkillsGrid />
<ModelDownloadProgress className="mb-4" />
</div>
</div>
</div>
+33
View File
@@ -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 (
<div className="h-full overflow-hidden flex flex-col z-10 relative">
<Routes>
<Route index element={<SettingsHome />} />
<Route path="connections" element={<ConnectionsPanel />} />
<Route path="messaging" element={<MessagingPanel />} />
<Route path="privacy" element={<PrivacyPanel />} />
<Route path="profile" element={<ProfilePanel />} />
<Route path="advanced" element={<AdvancedPanel />} />
<Route path="billing" element={<BillingPanel />} />
<Route path="team" element={<TeamPanel />} />
<Route path="team/members" element={<TeamMembersPanel />} />
<Route path="team/invites" element={<TeamInvitesPanel />} />
</Routes>
</div>
);
};
export default Settings;
-50
View File
@@ -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 (
<div className="min-h-full relative flex items-center justify-center">
{/* Main content */}
@@ -42,39 +25,6 @@ const Welcome = ({ isWeb }: WelcomeProps) => {
<p className="opacity-70 leading-relaxed">Are you ready for this?</p>
{/* Model download progress (desktop only) */}
{showProgress && (
<div className="mt-6">
{isDownloading && (
<div className="space-y-2">
<div className="w-full bg-stone-700/30 rounded-full h-1.5 overflow-hidden">
<div
className="bg-primary-500/80 h-full rounded-full transition-all duration-300 ease-out"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="text-xs opacity-50">
Downloading AI model... {progressPercent}%
<span className="ml-1 opacity-70">(~1.2 GB)</span>
</p>
</div>
)}
{isPreparing && <p className="text-xs opacity-50">Preparing AI model download...</p>}
{error && !isLoading && (
<div className="space-y-2">
<p className="text-xs text-coral-500/80">{error}</p>
<button
onClick={handleRetry}
className="text-xs text-primary-500 hover:text-primary-400 transition-colors">
Retry download
</button>
</div>
)}
</div>
)}
{/* Show Telegram login button in Tauri app, download screen on web */}
{!isWeb && (
<div className="mt-6">
-136
View File
@@ -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<ModelStatus>('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<boolean>('model_is_available');
console.log('[ModelProvider] Available:', avail);
if (!avail || cancelled) return;
status = await invoke<ModelStatus>('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<ModelStatus>('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<ModelStatus>('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;
+40 -6
View File
@@ -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<string, unknown> }>(
'skill-state-changed',
listen<{ skillId: string; state: Record<string, unknown> }>('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 () => {
+93 -3
View File
@@ -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<string, string> | 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);
});
}
// ---------------------------------------------------------------------------
+237
View File
@@ -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<string, string>;
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<string, number>();
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<PendingErrorReport, 'title' | 'message'>): 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<string, string>
): 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();
-2
View File
@@ -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({
-60
View File
@@ -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<ModelStatus>) {
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<boolean>) {
state.downloadTriggered = action.payload;
},
setModelLoading(state, action: PayloadAction<boolean>) {
state.loading = action.payload;
},
setModelError(state, action: PayloadAction<string | null>) {
state.error = action.payload;
if (action.payload) {
state.loading = false;
}
},
},
});
export const { setModelStatus, setDownloadTriggered, setModelLoading, setModelError } =
modelSlice.actions;
export default modelSlice.reducer;