mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
Merge remote-tracking branch 'upstream/develop'
This commit is contained in:
+1
-1
Submodule skills updated: 2275afb466...87d75f69b7
Generated
+1
-131
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user