mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
@@ -38,3 +38,5 @@ src-tauri/runtime-skill-*
|
||||
.ruff_cache
|
||||
.kotlin
|
||||
.cargo
|
||||
|
||||
CLAUDE.local.md
|
||||
|
||||
@@ -352,6 +352,7 @@ Key updates from recent commits (cd9ebcd to current):
|
||||
## Key Patterns
|
||||
|
||||
- **Code Quality**: ESLint and Prettier enforce code standards with Husky hooks. Use type-only imports (`import type`) and consolidate imports from same modules.
|
||||
- **No dynamic imports**: All imports must be static `import` statements at the top of the file. Do not use `await import()` or `import().then()` inside functions or code blocks. Use try/catch around Tauri API calls for non-Tauri environments instead.
|
||||
- **No localStorage**: Avoid `localStorage` and `sessionStorage`; use Redux (and persist) for app state. Remove any direct usage when working on affected code.
|
||||
- **AI System Integration**: Use `src/lib/ai/` for memory management, constitution loading, entity queries, and session capture. AI providers abstracted through interface pattern.
|
||||
- **V8 Skills Runtime**: Skills execute in V8 JavaScript engine on desktop platforms. Use `SkillProvider` for GitHub sync, `SkillsGrid` for management interface, and Rust runtime commands for lifecycle management. Platform filtering ensures skills only run on supported platforms.
|
||||
@@ -377,7 +378,7 @@ Key updates from recent commits (cd9ebcd to current):
|
||||
|
||||
- **macOS deep links**: Require `.app` bundle (not `tauri dev`). Clear WebKit caches when debugging stale content: `rm -rf ~/Library/WebKit/com.alphahuman.app ~/Library/Caches/com.alphahuman.app`
|
||||
- **Cargo caching**: May serve stale frontend assets on incremental builds. Run `cargo clean --manifest-path src-tauri/Cargo.toml` if the app shows outdated UI.
|
||||
- **`window.__TAURI__`**: Not available at module load time. Use dynamic `import()` and try/catch for Tauri plugin calls.
|
||||
- **`window.__TAURI__`**: Not available at module load time. Use static imports and try/catch around Tauri API calls (not around imports).
|
||||
- **Android background services**: RuntimeService requires notification permissions (API 33+) and foreground service type specification (API 34+). Use Android logging (`android_logger`) for debug output in logcat.
|
||||
- **V8 runtime limitations**: V8 engine is desktop-only. Android skills should use lightweight alternatives or server-side execution patterns.
|
||||
- **Socket connections**: Persistent Socket.io connections via Rust backend work better than WebView-based connections on mobile platforms.
|
||||
|
||||
@@ -7,3 +7,8 @@ todo
|
||||
|
||||
- integrate the payments flow properly
|
||||
skip the connect account page and goto the home page
|
||||
|
||||
[] - get android version out
|
||||
[] - get background proceeses done
|
||||
[] - get ai to summarize messages in the device and upload to the cloud
|
||||
[] - get all the remaining skills working
|
||||
|
||||
+1
-1
Submodule skills updated: 3112738083...9dc1855541
@@ -21,6 +21,8 @@ pub struct ModelStatusResponse {
|
||||
pub loaded: bool,
|
||||
/// Whether the model is currently being loaded or downloaded.
|
||||
pub loading: bool,
|
||||
/// Whether the model file has been downloaded to disk.
|
||||
pub downloaded: bool,
|
||||
/// Download progress (0.0 to 1.0) if downloading.
|
||||
pub download_progress: Option<f32>,
|
||||
/// Error message if loading failed.
|
||||
@@ -135,6 +137,7 @@ pub fn model_get_status() -> ModelStatusResponse {
|
||||
available: status.available,
|
||||
loaded: status.loaded,
|
||||
loading: status.loading,
|
||||
downloaded: status.downloaded,
|
||||
download_progress: status.download_progress,
|
||||
error: status.error,
|
||||
model_path: status.model_path,
|
||||
@@ -149,6 +152,7 @@ pub fn model_get_status() -> ModelStatusResponse {
|
||||
available: true,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
downloaded: false,
|
||||
download_progress: None,
|
||||
error: Some("MediaPipe LLM: Download a model to get started".to_string()),
|
||||
model_path: None,
|
||||
@@ -161,6 +165,7 @@ pub fn model_get_status() -> ModelStatusResponse {
|
||||
available: false,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
downloaded: false,
|
||||
download_progress: None,
|
||||
error: Some("Model not available on iOS".to_string()),
|
||||
model_path: None,
|
||||
@@ -248,6 +253,28 @@ pub async fn model_summarize(text: String, max_tokens: Option<u32>) -> Result<St
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -65,10 +65,16 @@ mod desktop {
|
||||
"autoStart": m.auto_start,
|
||||
"version": m.version,
|
||||
"description": m.description,
|
||||
"setup": m.setup.as_ref().map(|s| serde_json::json!({
|
||||
"required": s.required,
|
||||
"label": s.label,
|
||||
})),
|
||||
"setup": m.setup.as_ref().map(|s| {
|
||||
let mut obj = serde_json::json!({
|
||||
"required": s.required,
|
||||
"label": s.label,
|
||||
});
|
||||
if let Some(oauth) = &s.oauth {
|
||||
obj["oauth"] = oauth.clone();
|
||||
}
|
||||
obj
|
||||
}),
|
||||
"platforms": m.platforms,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -461,6 +461,7 @@ pub fn run() {
|
||||
model_is_available,
|
||||
model_get_status,
|
||||
model_ensure_loaded,
|
||||
model_start_download,
|
||||
model_generate,
|
||||
model_summarize,
|
||||
model_unload,
|
||||
@@ -553,6 +554,7 @@ pub fn run() {
|
||||
model_is_available,
|
||||
model_get_status,
|
||||
model_ensure_loaded,
|
||||
model_start_download,
|
||||
model_generate,
|
||||
model_summarize,
|
||||
model_unload,
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct SkillSetup {
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
pub label: Option<String>,
|
||||
/// OAuth configuration (provider, scopes, apiBaseUrl).
|
||||
pub oauth: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Raw manifest as it appears on disk.
|
||||
|
||||
@@ -193,8 +193,8 @@ impl QjsSkillInstance {
|
||||
// Load bootstrap
|
||||
let bootstrap_code = include_str!("../services/tdlib_v8/bootstrap.js");
|
||||
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bootstrap_code) {
|
||||
let err_str = format!("Bootstrap failed: {e}");
|
||||
return Err(err_str);
|
||||
let detail = format_js_exception(&js_ctx, &e);
|
||||
return Err(format!("Bootstrap failed: {detail}"));
|
||||
}
|
||||
|
||||
// Set skill ID
|
||||
@@ -203,12 +203,14 @@ impl QjsSkillInstance {
|
||||
skill_id.replace('"', r#"\""#)
|
||||
);
|
||||
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(bridge_code.as_bytes()) {
|
||||
return Err(format!("Skill init failed: {e}"));
|
||||
let detail = format_js_exception(&js_ctx, &e);
|
||||
return Err(format!("Skill init failed: {detail}"));
|
||||
}
|
||||
|
||||
// Execute the skill's entry point
|
||||
if let Err(e) = js_ctx.eval::<rquickjs::Value, _>(js_source.as_bytes()) {
|
||||
return Err(format!("Skill load failed: {e}"));
|
||||
let detail = format_js_exception(&js_ctx, &e);
|
||||
return Err(format!("Skill load failed: {detail}"));
|
||||
}
|
||||
|
||||
// Extract tool definitions
|
||||
@@ -410,8 +412,34 @@ async fn handle_message(
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::Rpc { method, params, reply } => {
|
||||
let args = serde_json::json!({ "method": method, "params": params });
|
||||
let result = handle_js_call(ctx, "onRpc", &args.to_string()).await;
|
||||
let result = match method.as_str() {
|
||||
"oauth/complete" => {
|
||||
// Set credential on the oauth bridge, then call onOAuthComplete
|
||||
let set_cred_code = format!(
|
||||
"if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{ globalThis.oauth.__setCredential({}); }}",
|
||||
serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string())
|
||||
);
|
||||
ctx.with(|js_ctx| {
|
||||
let _ = js_ctx.eval::<rquickjs::Value, _>(set_cred_code.as_bytes());
|
||||
}).await;
|
||||
let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_call(ctx, "onOAuthComplete", ¶ms_str).await
|
||||
}
|
||||
"oauth/revoked" => {
|
||||
// Clear credential on the oauth bridge, then call onOAuthRevoked
|
||||
let clear_code = "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) { globalThis.oauth.__setCredential(null); }";
|
||||
ctx.with(|js_ctx| {
|
||||
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
|
||||
}).await;
|
||||
let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_void_call(ctx, "onOAuthRevoked", ¶ms_str).await
|
||||
.map(|()| serde_json::json!({ "ok": true }))
|
||||
}
|
||||
_ => {
|
||||
let args = serde_json::json!({ "method": method, "params": params });
|
||||
handle_js_call(ctx, "onRpc", &args.to_string()).await
|
||||
}
|
||||
};
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
@@ -422,6 +450,41 @@ async fn handle_message(
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Extract a human-readable error message from a QuickJS exception.
|
||||
/// When rquickjs returns `Error::Exception`, the actual JS error value
|
||||
/// is stored in the context and retrieved with `Ctx::catch()`.
|
||||
fn format_js_exception(js_ctx: &rquickjs::Ctx<'_>, err: &rquickjs::Error) -> String {
|
||||
if !err.is_exception() {
|
||||
return format!("{err}");
|
||||
}
|
||||
|
||||
let exception = js_ctx.catch();
|
||||
|
||||
// Try to get .message and .stack from the exception object
|
||||
if let Some(obj) = exception.as_object() {
|
||||
let message: String = obj
|
||||
.get::<_, String>("message")
|
||||
.unwrap_or_default();
|
||||
let stack: String = obj
|
||||
.get::<_, String>("stack")
|
||||
.unwrap_or_default();
|
||||
|
||||
if !message.is_empty() {
|
||||
if !stack.is_empty() {
|
||||
return format!("{message}\n{stack}");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to stringify the exception value
|
||||
if let Ok(s) = exception.get::<String>() {
|
||||
return s;
|
||||
}
|
||||
|
||||
format!("{err}")
|
||||
}
|
||||
|
||||
/// Call a lifecycle function on the skill object.
|
||||
/// Looks for the skill at globalThis.__skill.default first, then falls back to globalThis.
|
||||
async fn call_lifecycle(ctx: &rquickjs::AsyncContext, name: &str) -> Result<(), String> {
|
||||
@@ -440,7 +503,10 @@ async fn call_lifecycle(ctx: &rquickjs::AsyncContext, name: &str) -> Result<(),
|
||||
}})()"#
|
||||
);
|
||||
js_ctx.eval::<rquickjs::Value, _>(code.as_bytes())
|
||||
.map_err(|e| format!("{name}() failed: {e}"))?;
|
||||
.map_err(|e| {
|
||||
let detail = format_js_exception(&js_ctx, &e);
|
||||
format!("{name}() failed: {detail}")
|
||||
})?;
|
||||
Ok(())
|
||||
}).await
|
||||
}
|
||||
@@ -517,7 +583,10 @@ async fn handle_tool_call(
|
||||
|
||||
match js_ctx.eval::<String, _>(code.as_bytes()) {
|
||||
Ok(s) => Ok(s),
|
||||
Err(e) => Err(format!("Tool execution failed: {e}"))
|
||||
Err(e) => {
|
||||
let detail = format_js_exception(&js_ctx, &e);
|
||||
Err(format!("Tool execution failed: {detail}"))
|
||||
}
|
||||
}
|
||||
}).await?;
|
||||
|
||||
@@ -611,7 +680,10 @@ async fn handle_js_call(
|
||||
|
||||
match js_ctx.eval::<String, _>(code.as_bytes()) {
|
||||
Ok(s) => Ok(s),
|
||||
Err(e) => Err(format!("{fn_name}() failed: {e}"))
|
||||
Err(e) => {
|
||||
let detail = format_js_exception(&js_ctx, &e);
|
||||
Err(format!("{fn_name}() failed: {detail}"))
|
||||
}
|
||||
}
|
||||
}).await?;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Provides:
|
||||
//! - Lazy model loading on first use
|
||||
//! - Automatic model download if not present
|
||||
//! - Resumable downloads with HTTP Range support
|
||||
//! - Thread-safe inference with dedicated thread pool
|
||||
//! - Generate and summarize API for skills
|
||||
|
||||
@@ -33,6 +34,13 @@ const MODEL_URL: &str = "https://huggingface.co/bartowski/google_gemma-3n-E2B-it
|
||||
/// Expected SHA256 hash for model verification (first 16 chars for quick check)
|
||||
const MODEL_SHA256_PREFIX: &str = ""; // Will be verified on first download
|
||||
|
||||
/// Sidecar metadata for resumable downloads.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct DownloadMeta {
|
||||
total_size: u64,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Status of the local model.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -43,6 +51,8 @@ pub struct ModelStatus {
|
||||
pub loaded: bool,
|
||||
/// Whether the model is currently being loaded or downloaded.
|
||||
pub loading: bool,
|
||||
/// Whether the model file has been downloaded to disk.
|
||||
pub downloaded: bool,
|
||||
/// Download progress (0.0 to 1.0) if downloading.
|
||||
pub download_progress: Option<f32>,
|
||||
/// Error message if loading failed.
|
||||
@@ -57,6 +67,7 @@ impl Default for ModelStatus {
|
||||
available: true,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
downloaded: false,
|
||||
download_progress: None,
|
||||
error: None,
|
||||
model_path: None,
|
||||
@@ -109,6 +120,8 @@ pub struct LlamaManager {
|
||||
status: RwLock<ModelStatus>,
|
||||
/// Lock to prevent concurrent loading.
|
||||
loading: AtomicBool,
|
||||
/// Lock to prevent concurrent downloads.
|
||||
downloading: AtomicBool,
|
||||
}
|
||||
|
||||
impl LlamaManager {
|
||||
@@ -119,6 +132,7 @@ impl LlamaManager {
|
||||
model: RwLock::new(None),
|
||||
status: RwLock::new(ModelStatus::default()),
|
||||
loading: AtomicBool::new(false),
|
||||
downloading: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,14 +141,19 @@ impl LlamaManager {
|
||||
log::info!("[llama] Setting data dir: {:?}", dir);
|
||||
*self.data_dir.write() = dir.clone();
|
||||
|
||||
// Update status with model path
|
||||
// Update status with model path and downloaded flag
|
||||
let model_path = dir.join(MODEL_FILENAME);
|
||||
self.status.write().model_path = Some(model_path.to_string_lossy().to_string());
|
||||
let mut status = self.status.write();
|
||||
status.model_path = Some(model_path.to_string_lossy().to_string());
|
||||
status.downloaded = model_path.exists();
|
||||
}
|
||||
|
||||
/// Get the current model status.
|
||||
pub fn get_status(&self) -> ModelStatus {
|
||||
self.status.read().clone()
|
||||
let mut status = self.status.read().clone();
|
||||
// Dynamically check downloaded state
|
||||
status.downloaded = self.model_exists();
|
||||
status
|
||||
}
|
||||
|
||||
/// Get the model file path.
|
||||
@@ -142,14 +161,87 @@ impl LlamaManager {
|
||||
self.data_dir.read().join(MODEL_FILENAME)
|
||||
}
|
||||
|
||||
/// Get the temp download file path.
|
||||
fn download_path(&self) -> PathBuf {
|
||||
self.model_path().with_extension("gguf.download")
|
||||
}
|
||||
|
||||
/// Get the download metadata sidecar file path.
|
||||
fn meta_path(&self) -> PathBuf {
|
||||
self.model_path().with_extension("gguf.download.meta")
|
||||
}
|
||||
|
||||
/// Check if the model file exists.
|
||||
fn model_exists(&self) -> bool {
|
||||
self.model_path().exists()
|
||||
}
|
||||
|
||||
/// Download the model from HuggingFace.
|
||||
/// Download the model file without loading into memory.
|
||||
/// Safe to call from the Welcome page (pre-auth).
|
||||
pub async fn download_only(&self) -> Result<(), String> {
|
||||
// Already downloaded
|
||||
if self.model_exists() {
|
||||
log::info!("[llama] Model already downloaded, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.download_model().await
|
||||
}
|
||||
|
||||
/// Download the model from HuggingFace with resume support.
|
||||
async fn download_model(&self) -> Result<(), String> {
|
||||
// Already downloaded
|
||||
if self.model_exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Prevent concurrent downloads — second caller waits
|
||||
if self.downloading.swap(true, Ordering::SeqCst) {
|
||||
log::info!("[llama] Download already in progress, waiting...");
|
||||
while self.downloading.load(Ordering::SeqCst) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
// Check if the other download succeeded
|
||||
if self.model_exists() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err("Download failed (another attempt)".to_string());
|
||||
}
|
||||
|
||||
// Update status
|
||||
{
|
||||
let mut status = self.status.write();
|
||||
status.loading = true;
|
||||
status.error = None;
|
||||
}
|
||||
|
||||
let result = self.download_model_inner().await;
|
||||
|
||||
// Update status based on result
|
||||
{
|
||||
let mut status = self.status.write();
|
||||
status.loading = false;
|
||||
status.download_progress = None;
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
status.downloaded = true;
|
||||
status.error = None;
|
||||
}
|
||||
Err(e) => {
|
||||
status.error = Some(e.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.downloading.store(false, Ordering::SeqCst);
|
||||
result
|
||||
}
|
||||
|
||||
/// Inner download logic with HTTP Range resume support.
|
||||
async fn download_model_inner(&self) -> Result<(), String> {
|
||||
let model_path = self.model_path();
|
||||
let mut download_path = self.download_path();
|
||||
let mut meta_path = self.meta_path();
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = model_path.parent() {
|
||||
@@ -157,33 +249,120 @@ impl LlamaManager {
|
||||
.map_err(|e| format!("Failed to create model directory: {}", e))?;
|
||||
}
|
||||
|
||||
log::info!("[llama] Downloading model from {}", MODEL_URL);
|
||||
self.status.write().download_progress = Some(0.0);
|
||||
// Check for existing partial download
|
||||
let (mut existing_size, mut resume) =
|
||||
self.check_resume_state(&download_path, &meta_path);
|
||||
|
||||
// Use reqwest for download
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(MODEL_URL)
|
||||
|
||||
// Build request — add Range header if resuming
|
||||
let mut request = client.get(MODEL_URL);
|
||||
if resume && existing_size > 0 {
|
||||
log::info!(
|
||||
"[llama] Resuming download from byte {}",
|
||||
existing_size
|
||||
);
|
||||
request = request.header("Range", format!("bytes={}-", existing_size));
|
||||
}
|
||||
|
||||
let mut response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()));
|
||||
let mut status_code = response.status().as_u16();
|
||||
|
||||
// If we got 206 but the file size changed, delete temp files and start fresh
|
||||
if status_code == 206 {
|
||||
let total = self.parse_content_range_total(&response, existing_size);
|
||||
let meta_total = self.read_meta_total(&meta_path);
|
||||
if let Some(mt) = meta_total {
|
||||
if mt != total {
|
||||
log::warn!(
|
||||
"[llama] Remote file size changed ({} vs {}), restarting download",
|
||||
mt,
|
||||
total
|
||||
);
|
||||
let _ = std::fs::remove_file(&download_path);
|
||||
let _ = std::fs::remove_file(&meta_path);
|
||||
|
||||
// Re-issue a fresh GET (no Range header)
|
||||
response = client
|
||||
.get(MODEL_URL)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to restart download: {}", e))?;
|
||||
status_code = response.status().as_u16();
|
||||
existing_size = 0;
|
||||
let _ = resume; // no longer resuming
|
||||
|
||||
// Re-read paths (unchanged, but reset state)
|
||||
download_path = self.download_path();
|
||||
meta_path = self.meta_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
// Determine download mode based on response
|
||||
let (mut file, start_offset, total_size) = match status_code {
|
||||
206 => {
|
||||
// Partial Content — resume accepted
|
||||
let total = self.parse_content_range_total(&response, existing_size);
|
||||
|
||||
// Create temp file for download
|
||||
let temp_path = model_path.with_extension("download");
|
||||
let mut file = std::fs::File::create(&temp_path)
|
||||
.map_err(|e| format!("Failed to create temp file: {}", e))?;
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&download_path)
|
||||
.map_err(|e| format!("Failed to open temp file for append: {}", e))?;
|
||||
|
||||
log::info!(
|
||||
"[llama] Resuming download: {}/{} bytes",
|
||||
existing_size,
|
||||
total
|
||||
);
|
||||
|
||||
(file, existing_size, total)
|
||||
}
|
||||
200 => {
|
||||
// OK — server ignored Range or fresh start
|
||||
let total = response.content_length().unwrap_or(0);
|
||||
|
||||
// Start fresh — truncate any partial download
|
||||
let file = std::fs::File::create(&download_path)
|
||||
.map_err(|e| format!("Failed to create temp file: {}", e))?;
|
||||
|
||||
// Write meta for future resume
|
||||
self.write_meta(&meta_path, total)?;
|
||||
|
||||
log::info!("[llama] Starting fresh download: {} bytes", total);
|
||||
|
||||
(file, 0u64, total)
|
||||
}
|
||||
416 => {
|
||||
// Range Not Satisfiable — file is already complete
|
||||
log::info!("[llama] Download already complete (416), renaming");
|
||||
let _ = std::fs::remove_file(&meta_path);
|
||||
std::fs::rename(&download_path, &model_path)
|
||||
.map_err(|e| format!("Failed to rename temp file: {}", e))?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("Download failed with status: {}", status_code));
|
||||
}
|
||||
};
|
||||
|
||||
// Set initial progress
|
||||
if total_size > 0 {
|
||||
let progress = start_offset as f32 / total_size as f32;
|
||||
self.status.write().download_progress = Some(progress);
|
||||
} else {
|
||||
self.status.write().download_progress = Some(0.0);
|
||||
}
|
||||
|
||||
// Stream the download
|
||||
use futures::StreamExt;
|
||||
use std::io::Write;
|
||||
let mut stream = response.bytes_stream();
|
||||
use futures::StreamExt;
|
||||
let mut downloaded = start_offset;
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Download error: {}", e))?;
|
||||
@@ -197,7 +376,10 @@ impl LlamaManager {
|
||||
self.status.write().download_progress = Some(progress);
|
||||
|
||||
// Log progress every 10%
|
||||
if (progress * 10.0) as u32 > ((downloaded - chunk.len() as u64) as f32 / total_size as f32 * 10.0) as u32 {
|
||||
let prev_pct =
|
||||
((downloaded - chunk.len() as u64) as f32 / total_size as f32 * 10.0) as u32;
|
||||
let curr_pct = (progress * 10.0) as u32;
|
||||
if curr_pct > prev_pct {
|
||||
log::info!("[llama] Download progress: {:.1}%", progress * 100.0);
|
||||
}
|
||||
}
|
||||
@@ -208,16 +390,136 @@ impl LlamaManager {
|
||||
.map_err(|e| format!("Failed to flush file: {}", e))?;
|
||||
drop(file);
|
||||
|
||||
// Rename temp file to final path
|
||||
std::fs::rename(&temp_path, &model_path)
|
||||
// Clean up meta file and rename temp → final
|
||||
let _ = std::fs::remove_file(&meta_path);
|
||||
std::fs::rename(&download_path, &model_path)
|
||||
.map_err(|e| format!("Failed to rename temp file: {}", e))?;
|
||||
|
||||
log::info!("[llama] Model downloaded successfully to {:?}", model_path);
|
||||
self.status.write().download_progress = None;
|
||||
log::info!(
|
||||
"[llama] Model downloaded successfully to {:?}",
|
||||
model_path
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if we can resume a previous partial download.
|
||||
/// Returns (existing_bytes, should_resume).
|
||||
fn check_resume_state(&self, download_path: &PathBuf, meta_path: &PathBuf) -> (u64, bool) {
|
||||
let file_exists = download_path.exists();
|
||||
let meta_exists = meta_path.exists();
|
||||
|
||||
if !file_exists {
|
||||
// No partial download — start fresh
|
||||
if meta_exists {
|
||||
let _ = std::fs::remove_file(meta_path);
|
||||
}
|
||||
return (0, false);
|
||||
}
|
||||
|
||||
// Get existing file size
|
||||
let existing_size = match std::fs::metadata(download_path) {
|
||||
Ok(m) => m.len(),
|
||||
Err(_) => {
|
||||
let _ = std::fs::remove_file(download_path);
|
||||
let _ = std::fs::remove_file(meta_path);
|
||||
return (0, false);
|
||||
}
|
||||
};
|
||||
|
||||
if existing_size == 0 {
|
||||
let _ = std::fs::remove_file(download_path);
|
||||
let _ = std::fs::remove_file(meta_path);
|
||||
return (0, false);
|
||||
}
|
||||
|
||||
// Validate meta file
|
||||
if !meta_exists {
|
||||
// No meta — can't verify, start fresh
|
||||
log::warn!("[llama] Partial download exists but no meta file, restarting");
|
||||
let _ = std::fs::remove_file(download_path);
|
||||
return (0, false);
|
||||
}
|
||||
|
||||
// Read and validate meta
|
||||
match std::fs::read_to_string(meta_path) {
|
||||
Ok(contents) => match serde_json::from_str::<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?
|
||||
|
||||
+85
@@ -823,6 +823,91 @@ globalThis.data = {
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Bridge API (credential management and authenticated proxy)
|
||||
// ============================================================================
|
||||
(function () {
|
||||
var __oauthCredential = null;
|
||||
|
||||
globalThis.oauth = {
|
||||
/** Get the current OAuth credential, or null if not connected. */
|
||||
getCredential: function () {
|
||||
return __oauthCredential;
|
||||
},
|
||||
|
||||
/**
|
||||
* Make an authenticated API request proxied through the backend.
|
||||
* Path is relative to manifest's apiBaseUrl.
|
||||
*/
|
||||
fetch: function (path, options) {
|
||||
if (!__oauthCredential) {
|
||||
return {
|
||||
status: 401,
|
||||
headers: {},
|
||||
body: JSON.stringify({ error: 'No OAuth credential. Complete OAuth setup first.' }),
|
||||
};
|
||||
}
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
|
||||
var jwtToken = __platform.env('JWT_TOKEN') || '';
|
||||
var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path;
|
||||
var proxyUrl = backendUrl + '/proxy/by-id/' + __oauthCredential.credentialId + '/' + cleanPath;
|
||||
var method = (options && options.method) || 'GET';
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if (jwtToken) {
|
||||
headers['Authorization'] = 'Bearer ' + jwtToken;
|
||||
}
|
||||
if (options && options.headers) {
|
||||
for (var k in options.headers) {
|
||||
headers[k] = options.headers[k];
|
||||
}
|
||||
}
|
||||
var fetchOpts = JSON.stringify({
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: options ? options.body : undefined,
|
||||
timeout: options ? options.timeout : undefined,
|
||||
});
|
||||
var result = __net.fetch(proxyUrl, fetchOpts);
|
||||
return JSON.parse(result);
|
||||
},
|
||||
|
||||
/** Revoke the current OAuth credential server-side. */
|
||||
revoke: function () {
|
||||
if (__oauthCredential) {
|
||||
try {
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
|
||||
var jwtToken = __platform.env('JWT_TOKEN') || '';
|
||||
var revokeOpts = JSON.stringify({
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + jwtToken,
|
||||
},
|
||||
});
|
||||
__net.fetch(
|
||||
backendUrl + '/auth/integrations/' + __oauthCredential.credentialId,
|
||||
revokeOpts
|
||||
);
|
||||
} catch (e) {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
__oauthCredential = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Internal: set credential (called by runtime on oauth/complete). */
|
||||
__setCredential: function (cred) {
|
||||
__oauthCredential = cred;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// Tools array (skills assign to this global)
|
||||
// ============================================================================
|
||||
globalThis.tools = [];
|
||||
|
||||
// ============================================================================
|
||||
// Cron Bridge API (placeholder - requires integration with CronScheduler)
|
||||
// ============================================================================
|
||||
|
||||
+21
-18
@@ -5,6 +5,7 @@ import { PersistGate } from 'redux-persist/integration/react';
|
||||
|
||||
import AppRoutes from './AppRoutes';
|
||||
import AIProvider from './providers/AIProvider';
|
||||
import ModelProvider from './providers/ModelProvider';
|
||||
import SkillProvider from './providers/SkillProvider';
|
||||
import SocketProvider from './providers/SocketProvider';
|
||||
import UserProvider from './providers/UserProvider';
|
||||
@@ -15,26 +16,28 @@ function App() {
|
||||
<Sentry.ErrorBoundary fallback={<div>Something went wrong.</div>}>
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<UserProvider>
|
||||
<SocketProvider>
|
||||
<AIProvider>
|
||||
<SkillProvider>
|
||||
<Router>
|
||||
<div className="relative min-h-screen">
|
||||
<div className="pointer-events-none fixed inset-x-0 top-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.
|
||||
<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.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-7">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</div>
|
||||
</Router>
|
||||
</SkillProvider>
|
||||
</AIProvider>
|
||||
</SocketProvider>
|
||||
</UserProvider>
|
||||
</Router>
|
||||
</SkillProvider>
|
||||
</AIProvider>
|
||||
</SocketProvider>
|
||||
</UserProvider>
|
||||
</ModelProvider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
+21
-7
@@ -15,13 +15,27 @@ import { isTauri } from './utils/tauriCommands';
|
||||
|
||||
const OnboardingRoute = () => {
|
||||
const isOnboarded = useAppSelector(selectIsOnboarded);
|
||||
|
||||
// If the user has already completed onboarding, skip this page and go home.
|
||||
// On first load, when onboarding status is unset/false, we allow showing onboarding.
|
||||
if (isOnboarded) return <Navigate to="/home" replace />;
|
||||
return <Onboarding />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Home route wrapper: shows Home by default.
|
||||
* Only redirects to onboarding when user profile is loaded and onboarding is not done.
|
||||
*/
|
||||
const HomeRoute = () => {
|
||||
const user = useAppSelector(state => state.user.user);
|
||||
const isOnboarded = useAppSelector(selectIsOnboarded);
|
||||
|
||||
// While user profile is still loading, show Home (avoid flash to onboarding)
|
||||
if (!user) return <Home />;
|
||||
|
||||
// User loaded but onboarding not done → redirect to onboarding
|
||||
if (!isOnboarded) return <Navigate to="/onboarding" replace />;
|
||||
|
||||
return <Home />;
|
||||
};
|
||||
|
||||
const AppRoutes = () => {
|
||||
const [isWeb, setIsWeb] = useState(false);
|
||||
|
||||
@@ -65,8 +79,8 @@ const AppRoutes = () => {
|
||||
<Route
|
||||
path="/home"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true} requireOnboarded={true} redirectTo="/onboarding">
|
||||
<Home />
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<HomeRoute />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -75,8 +89,8 @@ const AppRoutes = () => {
|
||||
<Route
|
||||
path="/settings/*"
|
||||
element={
|
||||
<ProtectedRoute requireAuth={true} requireOnboarded={true} redirectTo="/onboarding">
|
||||
<Home />
|
||||
<ProtectedRoute requireAuth={true}>
|
||||
<HomeRoute />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { selectIsOnboarded } from '../store/authSelectors';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
|
||||
/**
|
||||
* Default redirect component that routes users based on their auth and onboarding status
|
||||
* Default redirect component that routes users based on their auth status.
|
||||
* - Not logged in → / (Welcome page)
|
||||
* - Logged in but not onboarded → /onboarding
|
||||
* - Logged in and onboarded → /home
|
||||
* - Logged in → /home (Home handles onboarding redirect if needed)
|
||||
*/
|
||||
const DefaultRedirect = () => {
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
const isOnboarded = useAppSelector(selectIsOnboarded);
|
||||
|
||||
if (token && isOnboarded) {
|
||||
if (token) {
|
||||
return <Navigate to="/home" replace />;
|
||||
}
|
||||
|
||||
if (token && !isOnboarded) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
return <Navigate to="/" replace />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useModelStatus } from '../hooks/useModelStatus';
|
||||
@@ -11,7 +12,7 @@ const ModelDownloadProgress = ({
|
||||
className = '',
|
||||
showWhenLoaded = false,
|
||||
}: ModelDownloadProgressProps) => {
|
||||
const { isAvailable, isLoaded, isLoading, downloadProgress, error, ensureLoaded } =
|
||||
const { isAvailable, isLoaded, isLoading, isDownloaded, downloadProgress, error, ensureLoaded } =
|
||||
useModelStatus();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
@@ -19,7 +20,6 @@ const ModelDownloadProgress = ({
|
||||
// Detect mobile platform
|
||||
const detectMobile = async () => {
|
||||
try {
|
||||
const { platform } = await import('@tauri-apps/plugin-os');
|
||||
const currentPlatform = await platform();
|
||||
setIsMobile(currentPlatform === 'android' || currentPlatform === 'ios');
|
||||
} catch {
|
||||
@@ -63,8 +63,8 @@ const ModelDownloadProgress = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render if loaded and showWhenLoaded is false
|
||||
if (isLoaded && !showWhenLoaded && !isLoading) {
|
||||
// Hide when downloaded or loaded (nothing for the user to do)
|
||||
if ((isDownloaded || isLoaded) && !showWhenLoaded && !isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
import { selectIsOnboarded } from '../store/authSelectors';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
|
||||
interface PublicRouteProps {
|
||||
@@ -9,24 +8,18 @@ interface PublicRouteProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Public route component that redirects authenticated users
|
||||
* If logged in and onboarded -> redirect to /home
|
||||
* If logged in but not onboarded -> redirect to /onboarding
|
||||
* Public route component that redirects authenticated users to /home.
|
||||
* Home handles the onboarding redirect once the user profile is loaded.
|
||||
*/
|
||||
const PublicRoute = ({ children, redirectTo }: PublicRouteProps) => {
|
||||
const token = useAppSelector(state => state.auth.token);
|
||||
const isOnboarded = useAppSelector(selectIsOnboarded);
|
||||
|
||||
// If user is logged in and onboarded, redirect to home
|
||||
if (token && isOnboarded) {
|
||||
// If user is logged in, always go to home.
|
||||
// Home itself will redirect to onboarding if needed.
|
||||
if (token) {
|
||||
return <Navigate to={redirectTo || '/home'} replace />;
|
||||
}
|
||||
|
||||
// If user is logged in but not onboarded, redirect to onboarding
|
||||
if (token && !isOnboarded) {
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
// User is not logged in, show public route
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import GoogleIcon from '../assets/icons/GoogleIcon';
|
||||
@@ -188,7 +190,7 @@ function SkillActionButton({
|
||||
name: skill.name,
|
||||
version: '0.0.0',
|
||||
description: skill.description,
|
||||
runtime: 'v8',
|
||||
runtime: 'quickjs',
|
||||
});
|
||||
// If skill has setup, the manager will set setup_required status
|
||||
// and the grid will re-render with the "Setup" button
|
||||
@@ -292,7 +294,6 @@ export default function SkillsGrid() {
|
||||
// Detect mobile platform
|
||||
const detectMobile = async () => {
|
||||
try {
|
||||
const { platform } = await import('@tauri-apps/plugin-os');
|
||||
const currentPlatform = await platform();
|
||||
setIsMobile(currentPlatform === 'android' || currentPlatform === 'ios');
|
||||
} catch {
|
||||
@@ -305,7 +306,6 @@ export default function SkillsGrid() {
|
||||
// Load skills from the V8 runtime engine.
|
||||
const loadSkills = async () => {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const manifests = await invoke<Array<Record<string, unknown>>>('runtime_discover_skills');
|
||||
|
||||
console.log('manifests', manifests);
|
||||
|
||||
@@ -13,10 +13,10 @@ const SettingsHome = () => {
|
||||
closeSettings();
|
||||
};
|
||||
|
||||
const handleViewEncryptionKey = () => {
|
||||
// TODO: Show encryption key in a secure modal
|
||||
console.log('View encryption key');
|
||||
};
|
||||
// const handleViewEncryptionKey = () => {
|
||||
// // TODO: Show encryption key in a secure modal
|
||||
// console.log('View encryption key');
|
||||
// };
|
||||
|
||||
const handleDeleteAllData = () => {
|
||||
// TODO: Show confirmation dialog and delete all data
|
||||
@@ -64,63 +64,63 @@ const SettingsHome = () => {
|
||||
onClick: () => navigateToSettings('privacy'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
title: 'Profile',
|
||||
description: 'Update your profile information and preferences',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('profile'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: 'Advanced',
|
||||
description: 'Advanced configuration and developer options',
|
||||
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>
|
||||
),
|
||||
onClick: () => navigateToSettings('advanced'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'encryption',
|
||||
title: 'View Encryption Key',
|
||||
description: 'Access your encryption key for backup purposes',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 7a2 2 0 0 1 2 2m4 0a6 6 0 0 1-7.743 5.743L11 17H9v2H7v2H4a1 1 0 0 1-1-1v-2.586a1 1 0 0 1 .293-.707l5.964-5.964A6 6 0 1 1 21 9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: handleViewEncryptionKey,
|
||||
dangerous: false,
|
||||
},
|
||||
// {
|
||||
// id: 'profile',
|
||||
// title: 'Profile',
|
||||
// description: 'Update your profile information and preferences',
|
||||
// icon: (
|
||||
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
// <path
|
||||
// strokeLinecap="round"
|
||||
// strokeLinejoin="round"
|
||||
// strokeWidth={2}
|
||||
// d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
// />
|
||||
// </svg>
|
||||
// ),
|
||||
// onClick: () => navigateToSettings('profile'),
|
||||
// dangerous: false,
|
||||
// },
|
||||
// {
|
||||
// id: 'advanced',
|
||||
// title: 'Advanced',
|
||||
// description: 'Advanced configuration and developer options',
|
||||
// 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>
|
||||
// ),
|
||||
// onClick: () => navigateToSettings('advanced'),
|
||||
// dangerous: false,
|
||||
// },
|
||||
// {
|
||||
// id: 'encryption',
|
||||
// title: 'View Encryption Key',
|
||||
// description: 'Access your encryption key for backup purposes',
|
||||
// icon: (
|
||||
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
// <path
|
||||
// strokeLinecap="round"
|
||||
// strokeLinejoin="round"
|
||||
// strokeWidth={2}
|
||||
// d="M15 7a2 2 0 0 1 2 2m4 0a6 6 0 0 1-7.743 5.743L11 17H9v2H7v2H4a1 1 0 0 1-1-1v-2.586a1 1 0 0 1 .293-.707l5.964-5.964A6 6 0 1 1 21 9z"
|
||||
// />
|
||||
// </svg>
|
||||
// ),
|
||||
// onClick: handleViewEncryptionKey,
|
||||
// dangerous: false,
|
||||
// },
|
||||
{
|
||||
id: 'team',
|
||||
title: 'Team',
|
||||
@@ -140,7 +140,7 @@ const SettingsHome = () => {
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
title: 'Billing',
|
||||
title: 'Billing & Usage',
|
||||
description: 'Manage your subscription and payment methods',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -27,15 +27,11 @@ const BillingPanel = () => {
|
||||
const activeTeam = teams.find(t => t.team._id === activeTeamId);
|
||||
const teamName = activeTeam?.team.name;
|
||||
|
||||
// Derive plan from active team when available, fall back to user
|
||||
const currentTier: PlanTier =
|
||||
activeTeam?.team.subscription?.plan ?? user?.subscription?.plan ?? 'FREE';
|
||||
const hasActive =
|
||||
activeTeam?.team.subscription?.hasActiveSubscription ??
|
||||
user?.subscription?.hasActiveSubscription ??
|
||||
false;
|
||||
const planExpiry = activeTeam?.team.subscription?.planExpiry ?? user?.subscription?.planExpiry;
|
||||
const usage = activeTeam?.team.usage ?? user?.usage;
|
||||
// Derive plan from active team (team is source of truth)
|
||||
const currentTier: PlanTier = activeTeam?.team.subscription?.plan ?? 'FREE';
|
||||
const hasActive = activeTeam?.team.subscription?.hasActiveSubscription ?? false;
|
||||
const planExpiry = activeTeam?.team.subscription?.planExpiry;
|
||||
const usage = activeTeam?.team.usage;
|
||||
|
||||
// Local state
|
||||
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly');
|
||||
|
||||
@@ -11,7 +11,7 @@ const PrivacyPanel = () => {
|
||||
const analyticsEnabled = useAppSelector(state => {
|
||||
const userId = state.user.user?._id;
|
||||
if (!userId) return false;
|
||||
return state.auth.isAnalyticsEnabledByUser[userId] === true;
|
||||
return state.auth.isAnalyticsEnabledByUser[userId] !== false;
|
||||
});
|
||||
|
||||
const handleToggleAnalytics = () => {
|
||||
|
||||
@@ -22,9 +22,7 @@ const TeamInvitesPanel = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTeamId) {
|
||||
dispatch(fetchInvites(activeTeamId));
|
||||
}
|
||||
if (activeTeamId) dispatch(fetchInvites(activeTeamId));
|
||||
}, [activeTeamId, dispatch]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
|
||||
@@ -24,9 +24,7 @@ const TeamMembersPanel = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTeamId) {
|
||||
dispatch(fetchMembers(activeTeamId));
|
||||
}
|
||||
if (activeTeamId) dispatch(fetchMembers(activeTeamId));
|
||||
}, [activeTeamId, dispatch]);
|
||||
|
||||
const handleChangeRole = async (member: TeamMember, newRole: TeamRole) => {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* Multi-step setup wizard for a single skill.
|
||||
* Manages the state machine: start → render form → submit → next/error/complete.
|
||||
* Manages the state machine: start -> render form -> submit -> next/error/complete.
|
||||
* For OAuth skills, shows a login button instead of form steps.
|
||||
* Ensures the skill is running (starts it if needed) before starting the setup flow.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { store } from "../../store";
|
||||
import { useAppSelector } from "../../store/hooks";
|
||||
import { skillManager } from "../../lib/skills/manager";
|
||||
import { setSkillSetupComplete } from "../../store/skillsSlice";
|
||||
import { apiClient } from "../../services/apiClient";
|
||||
import { openUrl } from "../../utils/openUrl";
|
||||
import type { SetupStep, SetupFieldError } from "../../lib/skills/types";
|
||||
import SetupFormRenderer from "./SetupFormRenderer";
|
||||
|
||||
@@ -16,8 +21,15 @@ interface SkillSetupWizardProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface OAuthConfig {
|
||||
provider: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
type WizardState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "oauth"; oauth: OAuthConfig }
|
||||
| { phase: "oauth_waiting"; oauth: OAuthConfig }
|
||||
| { phase: "step"; step: SetupStep; errors?: SetupFieldError[] | null }
|
||||
| { phase: "submitting"; step: SetupStep }
|
||||
| { phase: "complete"; message?: string }
|
||||
@@ -30,6 +42,22 @@ export default function SkillSetupWizard({
|
||||
}: SkillSetupWizardProps) {
|
||||
const [state, setState] = useState<WizardState>({ phase: "loading" });
|
||||
|
||||
// Watch skill state for OAuth completion (skill pushes connected: true)
|
||||
const skillState = useAppSelector(
|
||||
(s) => s.skills.skillStates[skillId],
|
||||
);
|
||||
|
||||
// When skill state changes to connected during OAuth waiting, mark complete
|
||||
useEffect(() => {
|
||||
if (
|
||||
(state.phase === "oauth" || state.phase === "oauth_waiting") &&
|
||||
skillState?.connected === true
|
||||
) {
|
||||
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
|
||||
setState({ phase: "complete", message: "Successfully connected!" });
|
||||
}
|
||||
}, [skillState?.connected, state.phase, skillId]);
|
||||
|
||||
// Start the skill (if not running) then start the setup flow on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -68,11 +96,34 @@ export default function SkillSetupWizard({
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
// If the skill has OAuth config, show OAuth login instead of form steps
|
||||
if (manifest.setup?.oauth) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
phase: "oauth",
|
||||
oauth: {
|
||||
provider: manifest.setup.oauth.provider,
|
||||
scopes: manifest.setup.oauth.scopes,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SkillSetupWizard] starting setup", skillId);
|
||||
const firstStep = await skillManager.startSetup(skillId);
|
||||
console.log("[SkillSetupWizard] setup started", skillId);
|
||||
console.log("[SkillSetupWizard] setup started", skillId, firstStep);
|
||||
if (!cancelled) {
|
||||
setState({ phase: "step", step: firstStep });
|
||||
if (!firstStep) {
|
||||
// Skill doesn't implement setup steps — likely an OAuth-only skill
|
||||
// whose manifest.setup.oauth wasn't populated yet
|
||||
setState({
|
||||
phase: "error",
|
||||
message: "This skill requires OAuth setup but no setup steps were returned. Try restarting the app.",
|
||||
});
|
||||
} else {
|
||||
setState({ phase: "step", step: firstStep });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
@@ -89,6 +140,32 @@ export default function SkillSetupWizard({
|
||||
};
|
||||
}, [skillId]);
|
||||
|
||||
const handleOAuthLogin = useCallback(async () => {
|
||||
if (state.phase !== "oauth") return;
|
||||
|
||||
const { oauth } = state;
|
||||
|
||||
try {
|
||||
// Call backend to get the real OAuth authorization URL
|
||||
const data = await apiClient.get<{ oauthUrl?: string }>(
|
||||
`/auth/${oauth.provider}/connect?responseType=json&skillId=${skillId}`,
|
||||
);
|
||||
|
||||
if (!data.oauthUrl) {
|
||||
console.error("[SkillSetupWizard] Backend did not return oauthUrl:", data);
|
||||
setState({ phase: "error", message: "Failed to get OAuth URL from backend." });
|
||||
return;
|
||||
}
|
||||
|
||||
await openUrl(data.oauthUrl);
|
||||
setState({ phase: "oauth_waiting", oauth });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[SkillSetupWizard] OAuth connect error:", err);
|
||||
setState({ phase: "error", message: `OAuth connection failed: ${msg}` });
|
||||
}
|
||||
}, [state, skillId]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (values: Record<string, unknown>) => {
|
||||
if (state.phase !== "step") return;
|
||||
@@ -132,13 +209,15 @@ export default function SkillSetupWizard({
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
try {
|
||||
await skillManager.cancelSetup(skillId);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
if (state.phase !== "oauth" && state.phase !== "oauth_waiting") {
|
||||
try {
|
||||
await skillManager.cancelSetup(skillId);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
}
|
||||
onCancel();
|
||||
}, [skillId, onCancel]);
|
||||
}, [skillId, onCancel, state.phase]);
|
||||
|
||||
// Render based on current wizard state
|
||||
switch (state.phase) {
|
||||
@@ -171,6 +250,26 @@ export default function SkillSetupWizard({
|
||||
</div>
|
||||
);
|
||||
|
||||
case "oauth":
|
||||
return (
|
||||
<OAuthLoginView
|
||||
provider={state.oauth.provider}
|
||||
onLogin={handleOAuthLogin}
|
||||
onCancel={handleCancel}
|
||||
waiting={false}
|
||||
/>
|
||||
);
|
||||
|
||||
case "oauth_waiting":
|
||||
return (
|
||||
<OAuthLoginView
|
||||
provider={state.oauth.provider}
|
||||
onLogin={handleOAuthLogin}
|
||||
onCancel={handleCancel}
|
||||
waiting={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case "step":
|
||||
return (
|
||||
<SetupFormRenderer
|
||||
@@ -259,3 +358,198 @@ export default function SkillSetupWizard({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth Login View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatProviderName(provider: string): string {
|
||||
const names: Record<string, string> = {
|
||||
notion: "Notion",
|
||||
google: "Google",
|
||||
github: "GitHub",
|
||||
slack: "Slack",
|
||||
discord: "Discord",
|
||||
twitter: "Twitter",
|
||||
linear: "Linear",
|
||||
};
|
||||
return names[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
|
||||
interface OAuthLoginViewProps {
|
||||
provider: string;
|
||||
onLogin: () => void;
|
||||
onCancel: () => void;
|
||||
waiting: boolean;
|
||||
}
|
||||
|
||||
function OAuthLoginView({
|
||||
provider,
|
||||
onLogin,
|
||||
onCancel,
|
||||
waiting,
|
||||
}: OAuthLoginViewProps) {
|
||||
const providerName = formatProviderName(provider);
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
{/* Provider icon */}
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="w-14 h-14 rounded-2xl bg-stone-800 border border-stone-700 flex items-center justify-center">
|
||||
<ProviderIcon provider={provider} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title and description */}
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Connect to {providerName}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-400">
|
||||
{waiting
|
||||
? "Waiting for authorization. Complete the login in your browser..."
|
||||
: `Sign in with your ${providerName} account to connect this skill.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login button or waiting state */}
|
||||
{waiting ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-stone-800/50 border border-stone-700 rounded-xl">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-primary-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
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>
|
||||
<span className="text-sm text-stone-300">
|
||||
Waiting for {providerName} authorization...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
Open login page again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="w-full py-3 text-sm font-medium text-white bg-primary-500 rounded-xl hover:bg-primary-600 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with {providerName}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full py-2.5 text-sm font-medium text-stone-400 bg-stone-800/50 border border-stone-700 rounded-xl hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Icons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProviderIcon({ provider }: { provider: string }) {
|
||||
switch (provider) {
|
||||
case "notion":
|
||||
return (
|
||||
<svg className="w-7 h-7" viewBox="0 0 100 100" fill="none">
|
||||
<path
|
||||
d="M6.017 4.313l55.333-4.087c6.797-.583 8.543-.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277-1.553 6.807-6.99 7.193L24.467 99.967c-4.08.193-6.023-.39-8.16-3.113L3.3 79.94c-2.333-3.113-3.3-5.443-3.3-8.167V11.113c0-3.497 1.553-6.413 6.017-6.8z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M61.35.227L6.017 4.313C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723.967 5.053 3.3 8.167l12.993 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257-3.89c5.433-.387 6.99-2.917 6.99-7.193V20.64c0-2.21-.873-2.847-3.443-4.733L75.34 3.57l-.027-.02C71.587.527 69.087-.36 61.35.227zM25.723 19.01c-5.167.39-6.337.477-9.277-1.84l-6.44-5.057c-.773-.78-.39-1.75 1.553-1.947l52.893-3.89c4.473-.39 6.797 1.167 8.543 2.527l7.41 5.443c.39.193.97 1.357 0 1.357l-54.88 3.213-.003.003v-.01zm-6.6 73.793V28.883c0-2.53.777-3.697 3.107-3.89L81.44 21.78c2.14-.193 3.107 1.167 3.107 3.693v63.537c0 2.53-1.16 4.667-4.467 4.86L27.45 97.077c-3.113.193-4.337-.97-4.337-4.273h.01zm51.57-62.177c.39 1.75 0 3.5-1.75 3.7l-2.527.48v47.013c-2.14 1.167-4.273 1.75-5.637 1.75-2.53 0-3.303-.78-5.247-3.107l-16.08-25.283v24.477l5.25 1.17s0 3.5-4.857 3.5L28.337 79.4c-.39-.78 0-2.723 1.357-3.11l3.497-.97V40.763l-4.857-.39c-.39-1.75.583-4.277 3.3-4.473L42.63 35.32l16.667 25.477V37.853l-4.473-.58c-.39-2.14 1.163-3.5 3.303-3.697l12.567-.75z"
|
||||
fill="#000"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case "google":
|
||||
return (
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case "github":
|
||||
return (
|
||||
<svg className="w-7 h-7 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg
|
||||
className="w-7 h-7 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+43
-83
@@ -1,82 +1,62 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
setDownloadTriggered,
|
||||
setModelError,
|
||||
setModelLoading,
|
||||
setModelStatus,
|
||||
type ModelStatus,
|
||||
} from '../store/modelSlice';
|
||||
|
||||
/**
|
||||
* Model status from Rust backend
|
||||
* Hook to read model status from Redux and provide control actions.
|
||||
* Status polling and auto-download are handled by ModelProvider.
|
||||
*/
|
||||
export interface ModelStatus {
|
||||
available: boolean;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
downloadProgress: number | null;
|
||||
error: string | null;
|
||||
modelPath: string | null;
|
||||
}
|
||||
export const useModelStatus = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const model = useAppSelector(state => state.model);
|
||||
|
||||
const DEFAULT_STATUS: ModelStatus = {
|
||||
available: false,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
downloadProgress: null,
|
||||
error: null,
|
||||
modelPath: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to monitor and control local AI model status
|
||||
*/
|
||||
export const useModelStatus = (pollInterval = 1000) => {
|
||||
const [status, setStatus] = useState<ModelStatus>(DEFAULT_STATUS);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
|
||||
// Fetch current status from backend
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<ModelStatus>('model_get_status');
|
||||
setStatus(result);
|
||||
dispatch(setModelStatus(result));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[useModelStatus] Failed to fetch status:', error);
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch status',
|
||||
}));
|
||||
dispatch(setModelError(error instanceof Error ? error.message : 'Failed to fetch status'));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
// Check if model API is available
|
||||
const checkAvailability = useCallback(async () => {
|
||||
const startDownload = useCallback(async () => {
|
||||
try {
|
||||
const available = await invoke<boolean>('model_is_available');
|
||||
setStatus(prev => ({ ...prev, available }));
|
||||
return available;
|
||||
dispatch(setModelLoading(true));
|
||||
dispatch(setModelError(null));
|
||||
dispatch(setDownloadTriggered(true));
|
||||
await invoke('model_start_download');
|
||||
await fetchStatus();
|
||||
} catch (error) {
|
||||
console.error('[useModelStatus] Failed to check availability:', error);
|
||||
return false;
|
||||
console.error('[useModelStatus] Failed to start download:', error);
|
||||
dispatch(
|
||||
setModelError(error instanceof Error ? error.message : 'Failed to download model')
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [dispatch, fetchStatus]);
|
||||
|
||||
// Start loading/downloading the model
|
||||
const ensureLoaded = useCallback(async () => {
|
||||
try {
|
||||
setStatus(prev => ({ ...prev, loading: true, error: null }));
|
||||
setIsPolling(true);
|
||||
dispatch(setModelLoading(true));
|
||||
dispatch(setModelError(null));
|
||||
await invoke('model_ensure_loaded');
|
||||
await fetchStatus();
|
||||
setIsPolling(false);
|
||||
} catch (error) {
|
||||
console.error('[useModelStatus] Failed to load model:', error);
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load model',
|
||||
}));
|
||||
setIsPolling(false);
|
||||
dispatch(setModelError(error instanceof Error ? error.message : 'Failed to load model'));
|
||||
}
|
||||
}, [fetchStatus]);
|
||||
}, [dispatch, fetchStatus]);
|
||||
|
||||
// Unload the model from memory
|
||||
const unload = useCallback(async () => {
|
||||
try {
|
||||
await invoke('model_unload');
|
||||
@@ -86,39 +66,19 @@ export const useModelStatus = (pollInterval = 1000) => {
|
||||
}
|
||||
}, [fetchStatus]);
|
||||
|
||||
// Initial check and polling setup
|
||||
useEffect(() => {
|
||||
// Initial fetch
|
||||
fetchStatus();
|
||||
|
||||
// Check availability
|
||||
checkAvailability();
|
||||
}, [fetchStatus, checkAvailability]);
|
||||
|
||||
// Polling when loading/downloading
|
||||
useEffect(() => {
|
||||
if (!isPolling && !status.loading) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const newStatus = await fetchStatus();
|
||||
// Stop polling when loading is done
|
||||
if (newStatus && !newStatus.loading) {
|
||||
setIsPolling(false);
|
||||
}
|
||||
}, pollInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isPolling, status.loading, pollInterval, fetchStatus]);
|
||||
|
||||
return {
|
||||
status,
|
||||
isAvailable: status.available,
|
||||
isLoaded: status.loaded,
|
||||
isLoading: status.loading,
|
||||
downloadProgress: status.downloadProgress,
|
||||
error: status.error,
|
||||
status: model,
|
||||
isAvailable: model.available,
|
||||
isLoaded: model.loaded,
|
||||
isLoading: model.loading,
|
||||
isDownloaded: model.downloaded,
|
||||
downloadProgress: model.downloadProgress,
|
||||
error: model.error,
|
||||
startDownload,
|
||||
ensureLoaded,
|
||||
unload,
|
||||
refresh: fetchStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export type { ModelStatus };
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* and tool invocation. Dispatches status changes to Redux.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { SkillRuntime } from "./runtime";
|
||||
import type {
|
||||
SkillManifest,
|
||||
@@ -125,9 +127,10 @@ class SkillManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the setup flow for a skill. Returns the first step.
|
||||
* Start the setup flow for a skill. Returns the first step, or null if
|
||||
* the skill doesn't implement setup/start (e.g. OAuth-only skills).
|
||||
*/
|
||||
async startSetup(skillId: string): Promise<SetupStep> {
|
||||
async startSetup(skillId: string): Promise<SetupStep | null> {
|
||||
console.log("[SkillManager] startSetup", skillId);
|
||||
const runtime = this.runtimes.get(skillId);
|
||||
if (!runtime) {
|
||||
@@ -231,6 +234,34 @@ class SkillManager {
|
||||
await this.activateSkill(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a skill that OAuth completed successfully.
|
||||
* Called by the deep link handler after backend OAuth callback.
|
||||
*/
|
||||
async notifyOAuthComplete(
|
||||
skillId: string,
|
||||
integrationId: string,
|
||||
provider?: string,
|
||||
): Promise<void> {
|
||||
const runtime = this.runtimes.get(skillId);
|
||||
if (!runtime || !runtime.isRunning) {
|
||||
console.warn(`[SkillManager] Cannot notify OAuth complete: skill ${skillId} not running`);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = store.getState().skills.skills[skillId]?.manifest;
|
||||
|
||||
await runtime.oauthComplete({
|
||||
credentialId: integrationId,
|
||||
provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown",
|
||||
grantedScopes: manifest?.setup?.oauth?.scopes ?? [],
|
||||
});
|
||||
|
||||
// Mark setup as complete and activate
|
||||
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
|
||||
await this.activateSkill(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward session start to all ready skills.
|
||||
*/
|
||||
@@ -367,7 +398,6 @@ class SkillManager {
|
||||
case "data/read": {
|
||||
const filename = params.filename as string;
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const content = await invoke<string>("runtime_skill_data_read", {
|
||||
skillId,
|
||||
filename,
|
||||
@@ -382,7 +412,6 @@ class SkillManager {
|
||||
const filename = params.filename as string;
|
||||
const content = params.content as string;
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("runtime_skill_data_write", {
|
||||
skillId,
|
||||
filename,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Skill runtime — higher-level wrapper around SkillTransport
|
||||
* for managing a single skill's lifecycle.
|
||||
*
|
||||
* With V8, skills are managed by the Rust runtime engine.
|
||||
* Skills are managed by the Rust QuickJS runtime engine.
|
||||
* This class wraps the transport layer to provide the same API
|
||||
* that the SkillManager expects.
|
||||
*/
|
||||
@@ -29,7 +29,7 @@ export class SkillRuntime {
|
||||
|
||||
/**
|
||||
* Set a handler for reverse RPC calls from the skill process.
|
||||
* With V8, reverse RPC is handled by bridge globals, so this
|
||||
* Reverse RPC is handled by bridge globals, so this
|
||||
* is kept for API compatibility.
|
||||
*/
|
||||
onReverseRpc(handler: ReverseRpcHandler): void {
|
||||
@@ -38,12 +38,12 @@ export class SkillRuntime {
|
||||
|
||||
|
||||
/**
|
||||
* Start the skill in the V8 runtime engine.
|
||||
* Start the skill in the QuickJS runtime engine.
|
||||
* The Rust engine handles process management, so we just tell it to start
|
||||
* and then initialize the transport for RPC routing.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Start the skill in the Rust V8 runtime
|
||||
// Start the skill in the Rust QuickJS runtime
|
||||
await invoke("runtime_start_skill", { skillId: this.manifest.id });
|
||||
|
||||
// Initialize the transport for RPC routing
|
||||
@@ -53,7 +53,7 @@ export class SkillRuntime {
|
||||
|
||||
/**
|
||||
* Send skill/load with manifest + data dir.
|
||||
* With V8, loading is handled by the Rust engine during start_skill,
|
||||
* Loading is handled by the Rust engine during start_skill,
|
||||
* so this sends a no-op skill/load RPC for protocol compatibility.
|
||||
*/
|
||||
async load(additionalParams?: Record<string, unknown>): Promise<void> {
|
||||
@@ -69,13 +69,17 @@ export class SkillRuntime {
|
||||
|
||||
/**
|
||||
* Start the setup flow. Returns the first SetupStep.
|
||||
* Returns null if the skill does not implement setup/start (e.g. OAuth-only skills).
|
||||
*/
|
||||
async setupStart(): Promise<SetupStep> {
|
||||
async setupStart(): Promise<SetupStep | null> {
|
||||
console.log("[SkillRuntime] setupStart", this.skillId);
|
||||
const result = await this.transport.request<{ step: SetupStep }>(
|
||||
const result = await this.transport.request<{ step: SetupStep } | null>(
|
||||
"setup/start"
|
||||
);
|
||||
console.log("[SkillRuntime] setupStart result", this.skillId, result);
|
||||
if (!result || !result.step) {
|
||||
return null;
|
||||
}
|
||||
return result.step;
|
||||
}
|
||||
|
||||
@@ -160,6 +164,29 @@ export class SkillRuntime {
|
||||
await this.transport.request("skill/sessionEnd", { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the skill that OAuth completed successfully.
|
||||
* Sets the credential on the bridge and calls onOAuthComplete.
|
||||
*/
|
||||
async oauthComplete(args: {
|
||||
credentialId: string;
|
||||
provider: string;
|
||||
grantedScopes?: string[];
|
||||
accountLabel?: string;
|
||||
}): Promise<void> {
|
||||
await this.transport.request("oauth/complete", args as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the skill that an OAuth credential was revoked.
|
||||
*/
|
||||
async oauthRevoked(args: {
|
||||
credentialId: string;
|
||||
reason: string;
|
||||
}): Promise<void> {
|
||||
await this.transport.request("oauth/revoked", args as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload and stop the skill.
|
||||
*/
|
||||
|
||||
+16
-21
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* JSON-RPC 2.0 transport over Tauri IPC commands.
|
||||
*
|
||||
* Routes JSON-RPC requests to the Rust V8 runtime engine via
|
||||
* `invoke('runtime_rpc', ...)`. Replaces the previous stdin/stdout
|
||||
* subprocess transport while keeping the same public API.
|
||||
*
|
||||
* Reverse RPC (state/get, state/set, data/read, data/write) is now
|
||||
* handled by bridge globals inside the V8 engine, so the transport
|
||||
* no longer needs to handle reverse RPC from the skill.
|
||||
* Routes JSON-RPC requests to the Rust QuickJS runtime engine via
|
||||
* `invoke('runtime_rpc', ...)`. Reverse RPC (state/get, state/set,
|
||||
* data/read, data/write) is handled by bridge globals inside the
|
||||
* QuickJS engine.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export type ReverseRpcHandler = (
|
||||
method: string,
|
||||
params: Record<string, unknown>
|
||||
@@ -21,16 +20,16 @@ export class SkillTransport {
|
||||
|
||||
/**
|
||||
* Set a handler for reverse RPC calls from the skill process.
|
||||
* With V8, reverse RPC is handled by bridge globals, so this
|
||||
* With QuickJS, reverse RPC is handled by bridge globals, so this
|
||||
* is kept for API compatibility but is a no-op.
|
||||
*/
|
||||
onReverseRpc(_handler: ReverseRpcHandler): void {
|
||||
// No-op: V8 bridge globals handle state/data directly
|
||||
// No-op: QuickJS bridge globals handle state/data directly
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the transport for a skill.
|
||||
* With V8, the skill process is managed by the Rust runtime engine,
|
||||
* With QuickJS, the skill process is managed by the Rust runtime engine,
|
||||
* so this just stores the skill ID for routing RPC calls.
|
||||
*
|
||||
* @param skillId - The skill ID to route requests to.
|
||||
@@ -58,7 +57,6 @@ export class SkillTransport {
|
||||
hasParams: params !== undefined,
|
||||
});
|
||||
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const result = await invoke<T>("runtime_rpc", {
|
||||
skillId: this.skillId,
|
||||
method,
|
||||
@@ -89,25 +87,22 @@ export class SkillTransport {
|
||||
});
|
||||
|
||||
// Fire and forget
|
||||
import("@tauri-apps/api/core").then(({ invoke }) => {
|
||||
invoke("runtime_rpc", {
|
||||
skillId: this.skillId,
|
||||
method,
|
||||
params: params ?? {},
|
||||
}).catch((err: unknown) => {
|
||||
console.error("[skill-transport] Notification error:", err);
|
||||
});
|
||||
invoke("runtime_rpc", {
|
||||
skillId: this.skillId,
|
||||
method,
|
||||
params: params ?? {},
|
||||
}).catch((err: unknown) => {
|
||||
console.error("[skill-transport] Notification error:", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the transport. With V8, this is a no-op since the
|
||||
* Stop the transport. With QuickJS, this is a no-op since the
|
||||
* Rust engine manages skill lifecycle. Use runtime_stop_skill instead.
|
||||
*/
|
||||
async kill(): Promise<void> {
|
||||
if (this.skillId && this._started) {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("runtime_stop_skill", { skillId: this.skillId });
|
||||
} catch {
|
||||
// Skill may already be stopped
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface SkillManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
runtime: "python" | "node" | "deno" | "v8";
|
||||
runtime: "quickjs";
|
||||
entry?: string;
|
||||
tick_interval?: number;
|
||||
env?: string[];
|
||||
@@ -22,10 +22,17 @@ export interface SkillManifest {
|
||||
setup?: {
|
||||
required: boolean;
|
||||
label?: string;
|
||||
oauth?: {
|
||||
provider: string;
|
||||
scopes: string[];
|
||||
apiBaseUrl: string;
|
||||
};
|
||||
};
|
||||
/** Platform filter. When present, only listed platforms load this skill.
|
||||
* When absent or empty, the skill is available on all platforms. */
|
||||
platforms?: SkillPlatform[];
|
||||
/** When true, skill is hidden in production builds. */
|
||||
ignoreInProduction?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+4
-5
@@ -6,15 +6,14 @@ import App from './App';
|
||||
import './index.css';
|
||||
import './polyfills';
|
||||
import { initSentry } from './services/analytics';
|
||||
import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener';
|
||||
|
||||
// Initialize Sentry early (before React renders)
|
||||
initSentry();
|
||||
|
||||
// Deep link listener - lazy import to avoid running before Tauri IPC is ready
|
||||
import('./utils/desktopDeepLinkListener').then(m => {
|
||||
m.setupDesktopDeepLinkListener().catch(err => {
|
||||
console.error('[DeepLink] setup error:', err);
|
||||
});
|
||||
// Deep link listener — try/catch handles non-Tauri environments
|
||||
setupDesktopDeepLinkListener().catch(err => {
|
||||
console.error('[DeepLink] setup error:', err);
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
|
||||
+2
-2
@@ -37,9 +37,9 @@ const Home = () => {
|
||||
const showUpgradeCTA = currentPlan === 'FREE';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
<div className="min-h-full relative">
|
||||
{/* Content overlay */}
|
||||
<div className="relative z-10 min-h-screen flex flex-col">
|
||||
<div className="relative z-10 min-h-full flex flex-col">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ const Login = () => {
|
||||
|
||||
if (consumeError) {
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center">
|
||||
<div className="min-h-full relative flex items-center justify-center">
|
||||
<div className="relative z-10 max-w-md w-full mx-4 text-center">
|
||||
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
|
||||
<p className="opacity-90 text-coral mb-4">{consumeError}</p>
|
||||
@@ -55,7 +55,7 @@ const Login = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center">
|
||||
<div className="min-h-full relative flex items-center justify-center">
|
||||
<div className="relative z-10 max-w-md w-full mx-4 text-center">
|
||||
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||
|
||||
+53
-9
@@ -1,6 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import DownloadScreen from '../components/DownloadScreen';
|
||||
import TelegramLoginButton from '../components/TelegramLoginButton';
|
||||
import TypewriterGreeting from '../components/TypewriterGreeting';
|
||||
import { useModelStatus } from '../hooks/useModelStatus';
|
||||
|
||||
interface WelcomeProps {
|
||||
isWeb: boolean;
|
||||
@@ -11,16 +14,24 @@ const Welcome = ({ isWeb }: WelcomeProps) => {
|
||||
'Hello HAL9000! 👋',
|
||||
"Let's cook! 🔥",
|
||||
'The A-Team is here! 👊',
|
||||
// "Welcome to the exclusive club of crypto degenerates! 🎪🚀",
|
||||
// "Let's get you richer than a Nigerian prince's email! 👑💸",
|
||||
// "Ready to HODL like your life depends on it? 🤝💀",
|
||||
// "Welcome, future crypto millionaire (results not guaranteed)! 🎰💎",
|
||||
// "Time to make Wall Street bros jealous AF! 📈🔥",
|
||||
// "Ready to go to the moon? Pack light! 🌙🚀"
|
||||
];
|
||||
|
||||
const { isAvailable, isDownloaded, isLoading, downloadProgress, error, startDownload } =
|
||||
useModelStatus();
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
startDownload();
|
||||
}, [startDownload]);
|
||||
|
||||
const progressPercent = downloadProgress !== null ? Math.round(downloadProgress * 100) : 0;
|
||||
|
||||
// Determine what to show for download progress
|
||||
const showProgress = !isWeb && isAvailable && !isDownloaded;
|
||||
const isDownloading = isLoading && downloadProgress !== null;
|
||||
const isPreparing = isLoading && downloadProgress === null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center">
|
||||
<div className="min-h-full relative flex items-center justify-center">
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 max-w-md w-full mx-4 space-y-6">
|
||||
{/* Welcome card */}
|
||||
@@ -28,8 +39,6 @@ const Welcome = ({ isWeb }: WelcomeProps) => {
|
||||
{/* Greeting */}
|
||||
<TypewriterGreeting greetings={greetings} />
|
||||
|
||||
{/* <br /> */}
|
||||
|
||||
<p className="opacity-70 mb-8 leading-relaxed">
|
||||
Welcome to AlphaHuman. Your Telegram assistant here to get you 10x more done in your
|
||||
journey.
|
||||
@@ -37,6 +46,41 @@ 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">
|
||||
|
||||
@@ -63,7 +63,7 @@ const Onboarding = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative flex items-center justify-center">
|
||||
<div className="min-h-full relative flex items-center justify-center">
|
||||
<div className="relative z-10 max-w-lg w-full mx-4">
|
||||
<div className="flex justify-center mb-6">
|
||||
<LottieAnimation src={stepAnimations[currentStep - 1]} height={120} width={120} />
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
import {
|
||||
setDownloadTriggered,
|
||||
setModelError,
|
||||
setModelLoading,
|
||||
setModelStatus,
|
||||
type ModelStatus,
|
||||
} from '../store/modelSlice';
|
||||
|
||||
const POLL_INTERVAL = 1000;
|
||||
|
||||
/**
|
||||
* App-level provider that auto-starts model download on desktop
|
||||
* and keeps Redux model state in sync with the Rust backend.
|
||||
*/
|
||||
const ModelProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const loading = useAppSelector(state => state.model.loading);
|
||||
const downloadTriggered = useAppSelector(state => state.model.downloadTriggered);
|
||||
|
||||
// Single init effect: fetch status → check platform → auto-download if needed.
|
||||
// No ref guard — safe to re-run; Rust backend prevents concurrent downloads.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const init = async () => {
|
||||
// 1. Fetch initial status
|
||||
let status: ModelStatus;
|
||||
try {
|
||||
status = await invoke<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;
|
||||
@@ -1,41 +1,49 @@
|
||||
/**
|
||||
* Skill Provider — discovers and manages skill lifecycles.
|
||||
*
|
||||
* On mount (when authenticated): discovers skills from the V8 runtime
|
||||
* On mount (when authenticated): discovers skills from the QuickJS runtime
|
||||
* engine, registers them in Redux, and auto-starts skills with completed setup.
|
||||
*
|
||||
* The Rust V8 engine handles skill discovery and auto-start independently.
|
||||
* This provider bridges the Rust engine state with the frontend Redux store.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { type ReactNode, useEffect, useRef } from 'react';
|
||||
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import type { SkillManifest } from '../lib/skills/types';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { DEV_AUTO_LOAD_SKILL } from '../utils/config';
|
||||
import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function discoverSkills(): Promise<SkillManifest[]> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const raw = await invoke<Array<Record<string, unknown>>>('runtime_discover_skills');
|
||||
// Map the V8 manifest format to SkillManifest
|
||||
return raw.map(m => ({
|
||||
|
||||
const manifests: SkillManifest[] = raw.map(m => ({
|
||||
id: m.id as string,
|
||||
name: m.name as string,
|
||||
version: (m.version as string) || '0.0.0',
|
||||
description: (m.description as string) || '',
|
||||
runtime: 'v8' as const,
|
||||
runtime: 'quickjs' as const,
|
||||
entry: m.entry as string | undefined,
|
||||
ignoreInProduction: (m.ignoreInProduction as boolean) ?? false,
|
||||
setup: m.setup
|
||||
? {
|
||||
required: ((m.setup as Record<string, unknown>).required as boolean) ?? false,
|
||||
label: (m.setup as Record<string, unknown>).label as string | undefined,
|
||||
oauth: (m.setup as Record<string, unknown>).oauth as
|
||||
| { provider: string; scopes: string[]; apiBaseUrl: string }
|
||||
| undefined,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
// In production, filter out skills marked as dev-only
|
||||
if (!IS_DEV) {
|
||||
return manifests.filter(m => !m.ignoreInProduction);
|
||||
}
|
||||
|
||||
return manifests;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -89,7 +97,7 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
// Discover skills from the V8 runtime engine
|
||||
// Discover skills from the QuickJS runtime engine
|
||||
const manifests = await discoverSkills();
|
||||
await registerAndStart(manifests);
|
||||
} catch (err) {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function isAnalyticsEnabled(): boolean {
|
||||
const state = store.getState();
|
||||
const userId = state.user?.user?._id;
|
||||
if (!userId) return false;
|
||||
return state.auth.isAnalyticsEnabledByUser[userId] === true;
|
||||
return state.auth.isAnalyticsEnabledByUser[userId] !== false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,7 @@ import storage from 'redux-persist/lib/storage';
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import aiReducer from './aiSlice';
|
||||
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
|
||||
import modelReducer from './modelSlice';
|
||||
import skillsReducer from './skillsSlice';
|
||||
import socketReducer from './socketSlice';
|
||||
import teamReducer from './teamSlice';
|
||||
@@ -45,6 +46,7 @@ export const store = configureStore({
|
||||
ai: persistedAiReducer,
|
||||
skills: persistedSkillsReducer,
|
||||
team: teamReducer,
|
||||
model: modelReducer,
|
||||
},
|
||||
middleware: getDefaultMiddleware => {
|
||||
const middleware = getDefaultMiddleware({
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export interface ModelStatus {
|
||||
available: boolean;
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
downloaded: boolean;
|
||||
downloadProgress: number | null;
|
||||
error: string | null;
|
||||
modelPath: string | null;
|
||||
}
|
||||
|
||||
interface ModelState extends ModelStatus {
|
||||
/** Whether auto-download has been triggered this session */
|
||||
downloadTriggered: boolean;
|
||||
}
|
||||
|
||||
const initialState: ModelState = {
|
||||
available: false,
|
||||
loaded: false,
|
||||
loading: false,
|
||||
downloaded: false,
|
||||
downloadProgress: null,
|
||||
error: null,
|
||||
modelPath: null,
|
||||
downloadTriggered: false,
|
||||
};
|
||||
|
||||
const modelSlice = createSlice({
|
||||
name: 'model',
|
||||
initialState,
|
||||
reducers: {
|
||||
setModelStatus(state, action: PayloadAction<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;
|
||||
+1
-1
@@ -64,7 +64,7 @@ export interface User {
|
||||
username?: string;
|
||||
languageCode?: string;
|
||||
waitlist?: string;
|
||||
activeTeamId?: string;
|
||||
activeTeamId: string;
|
||||
}
|
||||
|
||||
// Billing types
|
||||
|
||||
@@ -1,15 +1,80 @@
|
||||
import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import { consumeLoginToken } from '../services/api/authApi';
|
||||
import { store } from '../store';
|
||||
import { setToken } from '../store/authSlice';
|
||||
import { IS_DEV } from './config';
|
||||
|
||||
/**
|
||||
* Handle an `alphahuman://auth?token=...` deep link for login.
|
||||
*/
|
||||
const handleAuthDeepLink = async (parsed: URL) => {
|
||||
const token = parsed.searchParams.get('token');
|
||||
if (!token) {
|
||||
console.warn('[DeepLink] URL did not contain a token query parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DeepLink] Received auth token');
|
||||
|
||||
try {
|
||||
await invoke('show_window');
|
||||
} catch (err) {
|
||||
console.warn('[DeepLink] Failed to show window:', err);
|
||||
}
|
||||
|
||||
const jwtToken = await consumeLoginToken(token);
|
||||
store.dispatch(setToken(jwtToken));
|
||||
window.location.hash = '/onboarding';
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle `alphahuman://oauth/success?integrationId=...&skillId=...`
|
||||
* and `alphahuman://oauth/error?error=...&provider=...` deep links.
|
||||
*/
|
||||
const handleOAuthDeepLink = async (parsed: URL) => {
|
||||
// pathname is "/success" or "/error" (hostname is "oauth")
|
||||
const path = parsed.pathname.replace(/^\/+/, '');
|
||||
|
||||
try {
|
||||
await invoke('show_window');
|
||||
} catch {
|
||||
// Not fatal
|
||||
}
|
||||
|
||||
if (path === 'success') {
|
||||
const integrationId = parsed.searchParams.get('integrationId');
|
||||
const skillId = parsed.searchParams.get('skillId');
|
||||
|
||||
if (!integrationId || !skillId) {
|
||||
console.error('[DeepLink] OAuth success missing integrationId or skillId', parsed.href);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`);
|
||||
|
||||
try {
|
||||
await skillManager.notifyOAuthComplete(skillId, integrationId);
|
||||
} catch (err) {
|
||||
console.error('[DeepLink] Failed to notify OAuth complete:', err);
|
||||
}
|
||||
} else if (path === 'error') {
|
||||
const error = parsed.searchParams.get('error') ?? 'Unknown error';
|
||||
const provider = parsed.searchParams.get('provider') ?? 'unknown';
|
||||
console.error(`[DeepLink] OAuth error for provider=${provider}: ${error}`);
|
||||
} else {
|
||||
console.warn('[DeepLink] Unknown OAuth path:', path);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a list of deep link URLs delivered by the Tauri deep-link plugin.
|
||||
* Parses `alphahuman://auth?token=...` URLs and exchanges the token for a
|
||||
* desktop session via the backend.
|
||||
* Routes to the appropriate handler based on the URL hostname:
|
||||
* - `alphahuman://auth?token=...` → login flow
|
||||
* - `alphahuman://oauth/success?...` → OAuth completion
|
||||
* - `alphahuman://oauth/error?...` → OAuth failure
|
||||
*/
|
||||
const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
|
||||
if (!urls || urls.length === 0) {
|
||||
@@ -23,33 +88,18 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
|
||||
if (parsed.protocol !== 'alphahuman:') {
|
||||
return;
|
||||
}
|
||||
// Harden: ensure this deep link is intended for auth handoff
|
||||
if (parsed.hostname !== 'auth') {
|
||||
return;
|
||||
|
||||
switch (parsed.hostname) {
|
||||
case 'auth':
|
||||
await handleAuthDeepLink(parsed);
|
||||
break;
|
||||
case 'oauth':
|
||||
await handleOAuthDeepLink(parsed);
|
||||
break;
|
||||
default:
|
||||
console.warn('[DeepLink] Unknown deep link hostname:', parsed.hostname);
|
||||
break;
|
||||
}
|
||||
|
||||
const token = parsed.searchParams.get('token');
|
||||
if (!token) {
|
||||
console.warn('[DeepLink] URL did not contain a token query parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DeepLink] Received token', token);
|
||||
|
||||
try {
|
||||
// Bring app window to foreground so macOS users actually see completion.
|
||||
// (In this app, the window can start hidden and live in the tray.)
|
||||
await invoke('show_window');
|
||||
} catch (err) {
|
||||
// Not fatal; we still continue the auth flow.
|
||||
console.warn('[DeepLink] Failed to show window:', err);
|
||||
}
|
||||
|
||||
const jwtToken = await consumeLoginToken(token);
|
||||
store.dispatch(setToken(jwtToken));
|
||||
|
||||
// Navigate to post-login flow. We use HashRouter, so update the hash route.
|
||||
window.location.hash = '/onboarding';
|
||||
} catch (error) {
|
||||
console.error('[DeepLink] Failed to handle deep link URL:', url, error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user