Merge pull request #54 from senamakel/feat/thursday

Feat/thursday
This commit is contained in:
Steven Enamakel
2026-02-06 06:16:30 +05:30
committed by GitHub
40 changed files with 1464 additions and 353 deletions
+2
View File
@@ -38,3 +38,5 @@ src-tauri/runtime-skill-*
.ruff_cache
.kotlin
.cargo
CLAUDE.local.md
+2 -1
View File
@@ -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.
+5
View File
@@ -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
+27
View File
@@ -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> {
+10 -4
View File
@@ -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,
})
})
+2
View File
@@ -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,
+2
View File
@@ -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.
+81 -9
View File
@@ -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(&params).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(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_call(ctx, "onOAuthComplete", &params_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(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_void_call(ctx, "onOAuthRevoked", &params_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?;
+325 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
}
/>
+3 -10
View File
@@ -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 />;
};
+4 -4
View File
@@ -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;
}
+5 -12
View File
@@ -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}</>;
};
+3 -3
View File
@@ -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);
+62 -62
View File
@@ -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) => {
+302 -8
View File
@@ -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
View File
@@ -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 };
+33 -4
View File
@@ -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,
+34 -7
View File
@@ -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
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+1 -1
View File
@@ -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} />
+137
View File
@@ -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;
+18 -10
View File
@@ -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) {
+1 -1
View File
@@ -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;
}
// ---------------------------------------------------------------------------
+2
View File
@@ -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({
+60
View File
@@ -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
View File
@@ -64,7 +64,7 @@ export interface User {
username?: string;
languageCode?: string;
waitlist?: string;
activeTeamId?: string;
activeTeamId: string;
}
// Billing types
+78 -28
View File
@@ -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);
}