Merge branch 'develop' of github.com:vezuresdotxyz/frontend-runner-alphahuman into develop

This commit is contained in:
Steven Enamakel
2026-02-10 10:24:17 +05:30
15 changed files with 182 additions and 32 deletions
+1 -1
Submodule skills updated: 87d75f69b7...26341b5297
+3 -2
View File
@@ -29,8 +29,9 @@ tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# HTTP client (rustls for cross-platform TLS support including Android)
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream"] }
# HTTP client: rustls for cross-platform TLS (Android), native-tls for desktop skill HTTP
# (some APIs/CDNs use TLS configs that rustls can't negotiate — native-tls uses OS TLS stack)
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream"] }
# Async runtime
tokio = { version = "1", features = ["full", "sync"] }
+12 -1
View File
@@ -51,6 +51,7 @@ fn do_fetch(url: &str, options_json: &str) -> Result<String, String> {
let timeout_secs = options.timeout.unwrap_or(30);
let client = reqwest::blocking::Client::builder()
.use_native_tls()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
@@ -74,7 +75,17 @@ fn do_fetch(url: &str, options_json: &str) -> Result<String, String> {
req = req.body(body);
}
let resp = req.send().map_err(|e| format!("HTTP request failed: {e}"))?;
let resp = req.send().map_err(|e| {
// Walk the full error chain so the caller sees the root cause
// (TLS, DNS, timeout, etc.) not just "error sending request for url".
let mut msg = format!("HTTP request failed: {e}");
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
msg.push_str(&format!(" | caused by: {cause}"));
source = std::error::Error::source(cause);
}
msg
})?;
let status = resp.status().as_u16();
let headers: HashMap<String, String> = resp
+66 -8
View File
@@ -227,6 +227,8 @@ impl QjsSkillInstance {
return;
}
restore_oauth_credential(&ctx, &config.skill_id).await;
// Call init() lifecycle
if let Err(e) = call_lifecycle(&ctx, "init").await {
let mut s = state.write();
@@ -389,6 +391,8 @@ async fn handle_message(
) -> bool {
match msg {
SkillMessage::CallTool { tool_name, arguments, reply } => {
// Lazy-load persisted OAuth credential before calling the tool
restore_oauth_credential(ctx, skill_id).await;
let result = handle_tool_call(ctx, &tool_name, arguments).await;
let _ = reply.send(result);
}
@@ -400,9 +404,23 @@ async fn handle_message(
}
SkillMessage::Stop { reply } => {
let _ = call_lifecycle(ctx, "stop").await;
// Clear OAuth credential from memory and mark as disconnected in store
let clear_code = r#"(function() {
if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {
globalThis.oauth.__setCredential(null);
}
if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {
globalThis.state.set('__oauth_credential', '');
}
})()"#;
ctx.with(|js_ctx| {
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
}).await;
state.write().status = SkillStatus::Stopped;
log::info!("[skill:{}] Stopped", skill_id);
log::info!("[skill:{}] Stopped (OAuth credential cleared)", skill_id);
let _ = reply.send(());
return true; // Signal to stop
}
SkillMessage::SetupStart { reply } => {
@@ -444,23 +462,40 @@ async fn handle_message(
SkillMessage::Rpc { method, params, reply } => {
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())
// Set credential on the oauth bridge + persist to store
let cred_json = serde_json::to_string(&params).unwrap_or_else(|_| "null".to_string());
let code = format!(
r#"(function() {{
if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{
globalThis.oauth.__setCredential({cred});
}}
if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {{
globalThis.state.set('__oauth_credential', {cred});
}}
}})()"#,
cred = cred_json
);
ctx.with(|js_ctx| {
let _ = js_ctx.eval::<rquickjs::Value, _>(set_cred_code.as_bytes());
let _ = js_ctx.eval::<rquickjs::Value, _>(code.as_bytes());
}).await;
log::info!("[skill:{}] OAuth credential set and persisted to store", skill_id);
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); }";
// Clear credential: set to empty string so it's clearly "disconnected"
let clear_code = r#"(function() {
if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {
globalThis.oauth.__setCredential(null);
}
if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {
globalThis.state.set('__oauth_credential', '');
}
})()"#;
ctx.with(|js_ctx| {
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
}).await;
log::info!("[skill:{}] OAuth credential cleared from store", skill_id);
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 }))
@@ -749,3 +784,26 @@ async fn handle_js_void_call(
.map(|_| ())
}).await
}
/// Load a persisted OAuth credential from the skill's store and inject it
/// into the JS context so tools have access to the credential.
/// An empty string means "disconnected" — only non-empty values are restored.
async fn restore_oauth_credential(ctx: &rquickjs::AsyncContext, skill_id: &str) {
let code = r#"(function() {
if (typeof globalThis.state === 'undefined' || typeof globalThis.oauth === 'undefined') return false;
var cred = globalThis.state.get('__oauth_credential');
if (cred && cred !== '' && globalThis.oauth.__setCredential) {
globalThis.oauth.__setCredential(cred);
return true;
}
return false;
})()"#;
let restored = ctx.with(|js_ctx| {
js_ctx.eval::<bool, _>(code.as_bytes()).unwrap_or(false)
}).await;
if restored {
log::info!("[skill:{}] Restored OAuth credential from store", skill_id);
}
}
+8 -8
View File
@@ -807,12 +807,12 @@ globalThis.data = {
// OAuth Bridge API (credential management and authenticated proxy)
// ============================================================================
(function () {
var __oauthCredential = null;
globalThis.__oauthCredential = null;
globalThis.oauth = {
/** Get the current OAuth credential, or null if not connected. */
getCredential: function () {
return __oauthCredential;
return globalThis.__oauthCredential;
},
/**
@@ -820,7 +820,7 @@ globalThis.data = {
* Path is relative to manifest's apiBaseUrl.
*/
fetch: function (path, options) {
if (!__oauthCredential) {
if (!globalThis.__oauthCredential) {
return {
status: 401,
headers: {},
@@ -828,9 +828,9 @@ globalThis.data = {
};
}
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
var jwtToken = __platform.env('JWT_TOKEN') || '';
var jwtToken = __ops.get_session_token() || '';
var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path;
var proxyUrl = backendUrl + '/proxy/by-id/' + __oauthCredential.credentialId + '/' + cleanPath;
var proxyUrl = backendUrl + '/proxy/by-id/' + globalThis.__oauthCredential.credentialId + '/' + cleanPath;
var method = (options && options.method) || 'GET';
var headers = { 'Content-Type': 'application/json' };
if (jwtToken) {
@@ -856,7 +856,7 @@ globalThis.data = {
if (__oauthCredential) {
try {
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
var jwtToken = __platform.env('JWT_TOKEN') || '';
var jwtToken = __ops.get_session_token() || '';
var revokeOpts = JSON.stringify({
method: 'DELETE',
headers: {
@@ -993,7 +993,7 @@ globalThis.model = {
*/
generate: function (prompt, options) {
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
var jwtToken = __platform.env('JWT_TOKEN') || '';
var jwtToken = __ops.get_session_token() || '';
var body = { prompt: prompt };
if (options && options.maxTokens) body.maxTokens = options.maxTokens;
if (options && options.temperature) body.temperature = options.temperature;
@@ -1023,7 +1023,7 @@ globalThis.model = {
*/
summarize: function (text, options) {
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
var jwtToken = __platform.env('JWT_TOKEN') || '';
var jwtToken = __ops.get_session_token() || '';
var body = { text: text };
if (options && options.maxTokens) body.maxTokens = options.maxTokens;
var result = __net.fetch(backendUrl + '/api/ai/summarize', JSON.stringify({
@@ -81,6 +81,12 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, timer_state: Arc<RwLock<
}
}))?;
ops.set("get_session_token", Function::new(ctx.clone(), || -> String {
let token = crate::commands::auth::SESSION_SERVICE.get_token().unwrap_or_default();
log::info!("[js] get_session_token: {}", token);
return token;
}))?;
// ========================================================================
// Timers (2)
// ========================================================================
@@ -21,7 +21,10 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<Web
let headers_obj = opts["headers"].as_object();
let body = opts["body"].as_str();
let client = reqwest::Client::new();
let client = reqwest::Client::builder()
.use_native_tls()
.build()
.map_err(|e| js_err(e.to_string()))?;
let mut req = match method {
"GET" => client.get(&url),
"POST" => client.post(&url),
@@ -42,7 +45,15 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<Web
req = req.body(b.to_string());
}
let response = req.send().await.map_err(|e| js_err(e.to_string()))?;
let response = req.send().await.map_err(|e| {
let mut msg = e.to_string();
let mut source = std::error::Error::source(&e);
while let Some(cause) = source {
msg.push_str(&format!(" | caused by: {cause}"));
source = std::error::Error::source(cause);
}
js_err(msg)
})?;
let status = response.status().as_u16();
let status_text = response.status().canonical_reason().unwrap_or("").to_string();
@@ -121,6 +121,8 @@ pub struct WebSocketState {
pub const ALLOWED_ENV_VARS: &[&str] = &[
"VITE_BACKEND_URL",
"BACKEND_URL",
"JWT_TOKEN",
"VITE_TELEGRAM_BOT_USERNAME",
"VITE_TELEGRAM_BOT_ID",
"NODE_ENV",
@@ -135,5 +137,5 @@ pub fn check_telegram_skill(skill_id: &str) -> Result<(), String> {
}
pub fn js_err(msg: String) -> rquickjs::Error {
rquickjs::Error::new_from_js_message("ops", "Error", msg)
rquickjs::Error::new_from_js_message("skill", "RuntimeError", msg)
}
+1 -1
View File
@@ -9,7 +9,7 @@ interface ConnectionIndicatorProps {
const ConnectionIndicator = ({
status: overrideStatus,
description = 'Your browser is now connected to the AlphaHuman AI. Keep the app running to keep the connection alive. You can message your assistant with the button below.',
description = 'Your device is now connected to the AlphaHuman AI. Keep the app running to keep the connection alive. You can message your assistant with the button below.',
className = '',
}: ConnectionIndicatorProps) => {
// Use socket store status, but allow override via props
+17 -2
View File
@@ -22,6 +22,7 @@ import {
setSkillStatus,
setSkillError,
setSkillSetupComplete,
setSkillOAuthCredential,
setSkillTools,
setSkillState,
} from "../../store/skillsSlice";
@@ -98,6 +99,15 @@ class SkillManager {
if (setupRequired) {
store.dispatch(setSkillStatus({ skillId, status: "setup_required" }));
} else {
// Re-inject persisted OAuth credential if available
const oauthCred = skillState?.oauthCredential;
if (oauthCred) {
try {
await runtime.oauthComplete(oauthCred);
} catch (err) {
console.warn(`[SkillManager] Failed to restore OAuth credential for ${skillId}:`, err);
}
}
// Skill is ready — list tools
await this.activateSkill(skillId);
}
@@ -251,12 +261,16 @@ class SkillManager {
const manifest = store.getState().skills.skills[skillId]?.manifest;
await runtime.oauthComplete({
const credential = {
credentialId: integrationId,
provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown",
grantedScopes: manifest?.setup?.oauth?.scopes ?? [],
});
};
await runtime.oauthComplete(credential);
// Persist credential so it survives app restarts
store.dispatch(setSkillOAuthCredential({ skillId, credential }));
// Mark setup as complete and activate
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
await this.activateSkill(skillId);
@@ -298,6 +312,7 @@ class SkillManager {
async disconnectSkill(skillId: string): Promise<void> {
await this.stopSkill(skillId);
store.dispatch(setSkillSetupComplete({ skillId, complete: false }));
store.dispatch(setSkillOAuthCredential({ skillId, credential: undefined }));
store.dispatch(setSkillState({ skillId, state: {} }));
}
+8
View File
@@ -182,11 +182,19 @@ export interface SkillHostConnectionState {
// Redux skill state shape
// ---------------------------------------------------------------------------
export interface OAuthCredential {
credentialId: string;
provider: string;
grantedScopes?: string[];
}
export interface SkillState {
manifest: SkillManifest;
status: SkillStatus;
error?: string;
setupComplete: boolean;
tools: SkillToolDefinition[];
/** Persisted OAuth credential so it survives app restarts. */
oauthCredential?: OAuthCredential;
}
@@ -1,7 +1,5 @@
import { useState } from 'react';
import ConnectionIndicator from '../../../components/ConnectionIndicator';
interface GetStartedStepProps {
onComplete: () => void | Promise<void>;
}
@@ -32,8 +30,6 @@ const GetStartedStep = ({ onComplete }: GetStartedStepProps) => {
</p>
</div>
<ConnectionIndicator description="Your browser is now connected to the AlphaHuman AI Models. Please keep this tab open." />
{error && <p className="text-coral-500 text-sm mb-3 text-center">{error}</p>}
<button
+34 -1
View File
@@ -1,3 +1,4 @@
import type { Middleware } from '@reduxjs/toolkit';
import { configureStore } from '@reduxjs/toolkit';
import { createLogger } from 'redux-logger';
import {
@@ -13,6 +14,7 @@ import {
import storage from 'redux-persist/lib/storage';
import { IS_DEV } from '../utils/config';
import { storeSession } from '../utils/tauriCommands';
import aiReducer from './aiSlice';
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
import skillsReducer from './skillsSlice';
@@ -37,6 +39,37 @@ const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer);
const persistedSkillsReducer = persistReducer(skillsPersistConfig, skillsReducer);
/**
* Middleware that syncs the JWT token to the Rust SESSION_SERVICE whenever
* setToken is dispatched or auth state is rehydrated from persist.
*/
const syncTokenToRust: Middleware = () => next => action => {
const result = next(action);
const syncToken = (token: string) => {
// Pass a minimal user object — the token is what matters for SESSION_SERVICE
storeSession(token, { id: '' }).catch(err =>
console.warn('[syncTokenToRust] Failed to sync token:', err)
);
};
// Sync on explicit setToken
if (setToken.match(action) && action.payload) {
syncToken(action.payload);
}
// Sync on rehydration (app restart — persist loads token from localStorage)
const a = action as { type?: string; key?: string; payload?: { token?: string } };
if (a.type === REHYDRATE && a.key === 'auth') {
const token = a.payload?.token;
if (token) {
syncToken(token);
}
}
return result;
};
export const store = configureStore({
reducer: {
auth: persistedAuthReducer,
@@ -49,7 +82,7 @@ export const store = configureStore({
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({
serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] },
});
}).concat(syncTokenToRust);
// Add redux-logger in development with collapsed groups
if (IS_DEV) {
+9
View File
@@ -1,6 +1,7 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type {
OAuthCredential,
SkillManifest,
SkillState,
SkillStatus,
@@ -57,6 +58,13 @@ const skillsSlice = createSlice({
}
},
setSkillOAuthCredential(state, action: PayloadAction<{ skillId: string; credential: OAuthCredential | undefined }>) {
const { skillId, credential } = action.payload;
if (state.skills[skillId]) {
state.skills[skillId].oauthCredential = credential;
}
},
setSkillTools(state, action: PayloadAction<{ skillId: string; tools: SkillToolDefinition[] }>) {
const { skillId, tools } = action.payload;
if (state.skills[skillId]) {
@@ -87,6 +95,7 @@ export const {
setSkillStatus,
setSkillError,
setSkillSetupComplete,
setSkillOAuthCredential,
setSkillTools,
setSkillState,
removeSkill,
+1 -1
View File
@@ -127,7 +127,7 @@ export const setupDesktopDeepLinkListener = async () => {
if (typeof window !== 'undefined') {
// window.__simulateDeepLink('alphahuman://auth?token=1234567890')
// window.__simulateDeepLink('alphahuman://oauth/success?integrationId=6989178fad6dbfe9b137f577&skillId=notion')
// window.__simulateDeepLink('alphahuman://oauth/success?integrationId=6989ef9c8e8bf1b6d991a08c&skillId=notion')
(
window as Window & { __simulateDeepLink?: (url: string) => Promise<void> }
).__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]);