Feat/final flow check (#155)

* feat: add mnemonic recovery flow and encryption key management

- Introduced a new Mnemonic page for users to generate or import their recovery phrase.
- Updated routing to redirect users to the Mnemonic page after onboarding.
- Enhanced state management to store and retrieve AES encryption keys derived from the mnemonic.
- Added utility functions for generating and validating BIP39 mnemonic phrases.
- Updated selectors and reducers to handle encryption key state in the auth slice.
- Included new dependencies for cryptographic operations.

* feat: integrate Web3 wallet functionality and enhance state management

- Added WalletInfoSection component to display connected wallet address, network, and balance.
- Updated auth slice to manage primary wallet address derived from mnemonic.
- Enhanced SkillManager to pass wallet address as load parameters for wallet skill.
- Introduced utility function to derive EVM wallet address from mnemonic.
- Updated dependencies for cryptographic operations related to wallet functionality.

* fix: improve error handling and network selection in WalletInfoSection

- Enhanced the parsing of network data to ensure robustness against undefined values.
- Updated network selection logic to prioritize valid entries and provide a fallback.
- Improved error logging for better debugging of wallet information loading issues.
- Adjusted balance retrieval to handle cases where chain_id may be missing.

* feat: add socket event contract for Tauri integration

- Documented the socket event contract for communication between the backend and frontend in Tauri mode.
- Introduced constants for `REQUEST_ENCRYPTION_KEY` and `ENCRYPTION_KEY_EVENT` to facilitate encryption key management.
- Updated `setupTauriSocketListeners` to handle the encryption key request and response flow.

* Refactor wallet information fetching and connection status handling

- Updated `WalletInfoSection` to improve loading state management and error handling during wallet info retrieval.
- Introduced a retry mechanism for fetching wallet information with a maximum of 5 attempts.
- Enhanced connection status logic in `SkillsGrid` to treat missing authentication status as connected.
- Added a new method in `SkillManager` to set the wallet address and notify the wallet skill accordingly.

* feat: add integration token fetching and decryption functionality

- Introduced `fetchIntegrationTokens` API call to retrieve encrypted OAuth tokens for integrations.
- Implemented decryption logic for integration tokens using a provided key.
- Enhanced deep link handling to support fetching and storing integration tokens upon successful OAuth login.
- Added utility functions for hex and base64 conversions to facilitate token decryption.
- Updated `authApi.ts` and `desktopDeepLinkListener.ts` to integrate new functionality.

* refactor: move integration token encryption/decryption logic to a new utility file

- Extracted encryption and decryption functions for integration tokens into `integrationTokensCrypto.ts`.
- Removed redundant functions from `desktopDeepLinkListener.ts` to streamline the code.
- Updated deep link handling to store only the encrypted tokens instead of decrypted ones.
- Improved type definitions for integration tokens and their payloads.

* feat: enhance Gmail skill integration and state management

- Updated `SkillManager` to accept an optional access token for Gmail during OAuth completion.
- Introduced `gmailSlice` to manage Gmail user profile and emails in the Redux store.
- Implemented synchronization of Gmail skill state with the Redux store to keep user data updated.
- Refactored `SkillProvider` to handle Gmail state changes and fetch initial state from the backend.
- Adjusted deep link handling to pass decrypted access tokens for Gmail integration.
- Modified API endpoint for onboarding completion to remove the Telegram prefix.

* feat: implement Gmail metadata synchronization to backend

- Added a new utility function `syncGmailMetadataToBackend` to emit Gmail profile and email summaries to the backend via the `integration:metadata-sync` socket event.
- Updated `SkillProvider` to call the new synchronization function, ensuring Gmail skill state is sent to the backend when updated.

* refactor: update Tauri socket event handling and improve Mnemonic component typing

- Revised the Tauri socket event contract to clarify that encryption key handling is managed via the API instead of the socket.
- Removed outdated socket event constants related to encryption key requests from `tauriSocket.ts`.
- Enhanced type definitions in the `Mnemonic` component by specifying the event type for keyboard events.

* fix: update Gmail provider constant and adjust metadata synchronization logic

- Changed the constant for the Google provider from 'google' to 'gmail' for clarity.
- Commented out the email metadata synchronization logic in `syncGmailMetadataToBackend` to prevent potential issues with undefined email states.

* feat: implement Gmail metadata synchronization service

- Added a new file `metadataSync.ts` to handle the synchronization of Gmail profile and email summaries to the backend via the `integration:metadata-sync` socket event.
- Updated import paths in `SkillProvider` to reflect the new location of the Gmail metadata synchronization function.
- Enhanced type definitions for Gmail profile and email summaries to ensure proper data structure during synchronization.

* refactor: clean up code formatting and improve readability across multiple components

- Standardized import statements and formatting in various files, including `WalletInfoSection`, `AgentChatPanel`, and `SkillsPanel`.
- Enhanced readability by adjusting line breaks and spacing in JSX elements and function definitions.
- Improved consistency in the use of arrow functions and destructuring across components.
- Updated type definitions and ensured proper alignment of code for better maintainability.

* feat: enhance error handling in WalletInfoSection and improve user feedback

- Added error reporting functionality in `WalletInfoSection` to capture and enqueue errors when wallet info fails to load.
- Updated `Mnemonic` component to handle user state more robustly, ensuring encryption key is only set if the user is loaded.
- Refactored `TauriCommandsPanel` to improve string comparisons for loading states by converting to lowercase.
- Adjusted deep link handling in `desktopDeepLinkListener` to use trimmed hex values for encryption key conversion.

* fix: improve balance parsing and error handling in WalletInfoSection

- Updated balance parsing logic in `WalletInfoSection` to ensure that non-finite values default to zero.
- Enhanced error handling in `desktopDeepLinkListener` by adding error reporting for invalid encryption keys and conversion failures, improving user feedback and debugging capabilities.

* Merge remote-tracking branch 'upstream/develop' into feat/final-flow-check

* chore: update submodule URL for skills repository to use HTTPS

* chore: update skills submodule to latest commit and enhance SkillsGrid component

- Updated the skills submodule to the latest commit for improved functionality.
- Refactored `SkillsGrid` to better handle skill setup logic, ensuring accurate representation of skills with setup requirements.
- Cleaned up code formatting and improved readability in various components, including `SkillActionButton` and `desktopDeepLinkListener`.

* feat: implement Notion metadata synchronization and user profile management

- Added a new service `metadataSync.ts` to handle synchronization of Notion user metadata to the backend via the `integration:metadata-sync` socket event.
- Introduced a new Redux slice `notionSlice.ts` for managing the Notion user profile state.
- Updated `SkillProvider` to fetch and sync Notion user profile upon connection, ensuring seamless integration with the backend.
- Enhanced the store configuration to include the new Notion reducer.

* refactor: remove unnecessary console warnings in SkillProvider

- Eliminated console warnings related to Notion user retrieval errors and auto-loading skills, streamlining the logging process for better clarity and reducing noise in the console output.
- Improved code readability by cleaning up commented-out warning messages that were no longer needed.
This commit is contained in:
Mega Mind
2026-03-04 14:33:17 +05:30
committed by GitHub
parent 6acdfef488
commit c3718d9e4f
45 changed files with 2142 additions and 544 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
[submodule "skills"]
path = skills
url = git@github.com:vezuresdotxyz/alphahuman-skills.git
url = https://github.com/vezuresdotxyz/alphahuman-skills.git
+4 -4
View File
@@ -54,16 +54,16 @@ AlphaHuman is designed to be simpler to deploy, cheaper to run, and more intelli
> **Early Beta** — AlphaHuman is under active development. Expect rough edges.
| Platform | Variant | Download |
| ----------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Platform | Variant | Download |
| ----------- | --------------------------- | -------------------------------------------------------------------------------------------------------------- |
| **macOS** | Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) |
| **macOS** | Intel | [`.dmg` (x64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) |
| **Windows** | x64 | [`.msi`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) |
| **Linux** | Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) |
| **Linux** | Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) |
| **Linux** | Universal | [`.AppImage`](https://github.com/alphahumanai/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) |
| **Android** | — | Coming soon |
| **iOS** | — | Coming soon |
| **Android** | — | Coming soon |
| **iOS** | — | Coming soon |
Browse all releases: [github.com/alphahumanai/alphahuman/releases](https://github.com/alphahumanai/alphahuman/releases)
+5 -5
View File
@@ -4,11 +4,11 @@
We provide security updates for the following versions of AlphaHuman:
| Version | Supported |
| ------- | ------------------ |
| Latest | :white_check_mark: |
| Previous minor | :white_check_mark: |
| Older | :x: |
| Version | Supported |
| -------------- | ------------------ |
| Latest | :white_check_mark: |
| Previous minor | :white_check_mark: |
| Older | :x: |
We recommend always running the [latest release](https://github.com/alphahumanxyz/alphahuman/releases/latest). AlphaHuman is in early beta; older versions may not receive patches.
+4
View File
@@ -170,6 +170,10 @@ const socket = io(BACKEND_URL, {
});
```
### Socket event contract (Tauri)
In Tauri mode, the Rust socket forwards server events to the frontend via the `server:event` Tauri event. The frontend listens and can emit back via `emitViaRustSocket` (see `utils/tauriSocket.ts`). Encryption key is handled via the API, not the socket.
## MTProto Service (`services/mtprotoService.ts`)
Telegram MTProto client singleton.
+4
View File
@@ -44,8 +44,12 @@
"prepare": "husky"
},
"dependencies": {
"@noble/hashes": "^2.0.1",
"@noble/secp256k1": "^2.0.0",
"@heroicons/react": "^2.2.0",
"@reduxjs/toolkit": "^2.11.2",
"@scure/bip32": "^1.5.1",
"@scure/bip39": "^2.0.1",
"@sentry/react": "^10.38.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-deep-link": "^2",
+1 -1
Submodule skills updated: 66ad4d7e3f...66ec30016f
+1 -1
View File
@@ -296,7 +296,7 @@ impl PingScheduler {
"skillId": skill_id,
"state": state_map,
});
let _ = handle.emit("skill-state-changed", &payload);
let _ = handle.emit_to("main", "skill-state-changed", payload);
}
}
}
+22 -2
View File
@@ -15,7 +15,7 @@ use crate::runtime::ping_scheduler::PingScheduler;
use crate::runtime::preferences::PreferencesStore;
use crate::runtime::skill_registry::SkillRegistry;
use crate::runtime::socket_manager::SocketManager;
use crate::runtime::types::{events, SkillSnapshot, SkillStatus, ToolResult};
use crate::runtime::types::{events, SkillMessage, SkillSnapshot, SkillStatus, ToolResult};
use crate::runtime::qjs_skill_instance::{BridgeDeps, QjsSkillInstance};
use crate::services::quickjs_libs::storage::IdbStorage;
@@ -489,7 +489,27 @@ impl RuntimeEngine {
use crate::runtime::types::SkillMessage;
match method {
"skill/load" => Ok(serde_json::json!({ "ok": true })),
"skill/load" => {
// Extract load params (exclude manifest and dataDir) and send to skill
let load_params: serde_json::Map<String, serde_json::Value> = params
.as_object()
.map(|obj| {
obj.iter()
.filter(|(k, _)| k.as_str() != "manifest" && k.as_str() != "dataDir")
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
.unwrap_or_default();
if !load_params.is_empty() {
let msg = SkillMessage::LoadParams {
params: serde_json::Value::Object(load_params),
};
if let Err(e) = self.registry.send_message(skill_id, msg) {
log::warn!("[runtime] Failed to send LoadParams to skill '{}': {}", skill_id, e);
}
}
Ok(serde_json::json!({ "ok": true }))
}
"setup/start" => {
log::info!("[runtime] setup/start for '{}'", skill_id);
+14 -8
View File
@@ -387,16 +387,16 @@ async fn run_event_loop(
.collect();
state.write().published_state = new_map.clone();
// Emit Tauri event so the frontend picks up the change
// Emit Tauri event to the main window so the frontend receives it (Tauri 2: target explicitly)
if let Some(handle) = app_handle {
use tauri::Emitter;
let _ = handle.emit(
"skill-state-changed",
serde_json::json!({
"skillId": skill_id,
"state": new_map,
}),
);
let payload = serde_json::json!({
"skillId": skill_id,
"state": new_map,
});
if let Err(e) = handle.emit_to("main", "skill-state-changed", payload) {
log::warn!("[skill:{}] Failed to emit skill-state-changed to main: {}", skill_id, e);
}
}
}
}
@@ -533,6 +533,12 @@ async fn handle_message(
let result = handle_js_void_call(rt, ctx, "onTick", "{}").await;
let _ = reply.send(result);
}
SkillMessage::LoadParams { params } => {
let params_str = serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
if let Err(e) = handle_js_void_call(rt, ctx, "onLoad", &params_str).await {
log::warn!("[skill:{}] onLoad failed (skill may not export it): {}", skill_id, e);
}
}
SkillMessage::Error { error_type, message, source, recoverable } => {
let args = serde_json::json!({
"type": error_type,
+8
View File
@@ -100,6 +100,11 @@ pub enum SkillMessage {
params: serde_json::Value,
reply: tokio::sync::oneshot::Sender<Result<serde_json::Value, String>>,
},
/// Load params from frontend (e.g. wallet address for wallet skill).
/// Delivered after skill/load RPC; skill may export onLoad(params) to receive them.
LoadParams {
params: serde_json::Value,
},
}
/// Result of executing a tool.
@@ -180,6 +185,9 @@ pub struct UnifiedSkillEntry {
pub description: String,
/// Tools exposed by this skill.
pub tools: Vec<ToolDefinition>,
/// Setup configuration from manifest.json (e.g. whether setup is required).
#[serde(skip_serializing_if = "Option::is_none")]
pub setup: Option<crate::runtime::manifest::SkillSetup>,
}
/// Standardized result returned by any skill execution in the unified registry.
@@ -136,6 +136,26 @@ pub fn check_telegram_skill(skill_id: &str) -> Result<(), String> {
}
}
pub fn js_err(msg: String) -> rquickjs::Error {
rquickjs::Error::new_from_js_message("skill", "RuntimeError", msg)
/// Sanitize error message for use with QuickJS/rquickjs.
/// Some messages (e.g. from SQLite "Invalid symbol 45, offset 19") can trigger
/// "Invalid symbol" when rquickjs creates the JS exception — avoid comma and hyphen.
fn sanitize_error_message(msg: &str) -> String {
msg.chars()
.map(|c| {
if c == ',' || c == '-' {
' '
} else if c.is_ascii() && !c.is_ascii_control() {
c
} else if c == '\n' || c == '\r' || c == '\t' {
' '
} else {
'?'
}
})
.collect()
}
pub fn js_err(msg: impl AsRef<str>) -> rquickjs::Error {
let sanitized = sanitize_error_message(msg.as_ref());
rquickjs::Error::new_from_js_message("skill", "RuntimeError", sanitized)
}
+4
View File
@@ -91,6 +91,7 @@ impl UnifiedSkillRegistry {
version: manifest.version.clone().unwrap_or_else(|| "0.1.0".to_string()),
description: manifest.description.clone().unwrap_or_default(),
tools,
setup: manifest.setup.clone(),
});
}
}
@@ -126,6 +127,7 @@ impl UnifiedSkillRegistry {
version: skill.version.clone(),
description: skill.description.clone(),
tools,
setup: None,
});
}
@@ -208,6 +210,7 @@ impl UnifiedSkillRegistry {
version: "1.0.0".to_string(),
description: spec.description,
tools: vec![],
setup: None,
})
}
"openclaw" => {
@@ -221,6 +224,7 @@ impl UnifiedSkillRegistry {
version: "1.0.0".to_string(),
description: spec.description,
tools: vec![],
setup: None,
})
}
other => Err(format!("Unknown skill_type: '{other}'. Use 'alphahuman' or 'openclaw'.")),
+22 -1
View File
@@ -10,19 +10,28 @@ import Home from './pages/Home';
import Intelligence from './pages/Intelligence';
import Invites from './pages/Invites';
import Login from './pages/Login';
import Mnemonic from './pages/Mnemonic';
import Onboarding from './pages/onboarding/Onboarding';
import Settings from './pages/Settings';
import Welcome from './pages/Welcome';
import { selectIsOnboarded } from './store/authSelectors';
import { selectHasEncryptionKey, selectIsOnboarded } from './store/authSelectors';
import { useAppSelector } from './store/hooks';
import { isTauri } from './utils/tauriCommands';
const OnboardingRoute = () => {
const isOnboarded = useAppSelector(selectIsOnboarded);
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
if (isOnboarded && !hasEncryptionKey) return <Navigate to="/mnemonic" replace />;
if (isOnboarded) return <Navigate to="/home" replace />;
return <Onboarding />;
};
const MnemonicRoute = () => {
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
if (hasEncryptionKey) return <Navigate to="/home" replace />;
return <Mnemonic />;
};
/**
* Home route wrapper: shows Home by default.
* Only redirects to onboarding when user profile is loaded and onboarding is not done.
@@ -30,6 +39,7 @@ const OnboardingRoute = () => {
const HomeRoute = () => {
const user = useAppSelector(state => state.user.user);
const isOnboarded = useAppSelector(selectIsOnboarded);
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
// While user profile is still loading, show Home (avoid flash to onboarding)
if (!user) return <Home />;
@@ -37,6 +47,9 @@ const HomeRoute = () => {
// User loaded but onboarding not done → redirect to onboarding
if (!isOnboarded) return <Navigate to="/onboarding" replace />;
// Onboarded but no encryption key → redirect to mnemonic page
if (!hasEncryptionKey) return <Navigate to="/mnemonic" replace />;
return <Home />;
};
@@ -80,6 +93,14 @@ const AppRoutes = () => {
</ProtectedRoute>
}
/>
<Route
path="/mnemonic"
element={
<ProtectedRoute requireAuth={true}>
<MnemonicRoute />
</ProtectedRoute>
}
/>
<Route
path="/home"
element={
+28 -21
View File
@@ -19,16 +19,24 @@ import SkillSetupModal from './skills/SkillSetupModal';
/** Normalize a raw unified registry entry into a SkillListEntry for display. */
function normalizeUnifiedEntry(e: Record<string, unknown>): SkillListEntry {
const setup = e.setup as Record<string, unknown> | undefined;
const setup = e.setup as { required?: boolean; oauth?: unknown } | undefined;
// Treat both interactive setup steps and OAuth-only flows as "has setup"
// so that clicking a skill (e.g. Gmail) opens the connection/setup wizard
// instead of jumping straight to the management panel.
const hasSetup =
!!setup &&
(setup.required === true ||
// OAuth config means we still need a connection step in the wizard
!!setup.oauth);
return {
id: e.id as string,
name:
(e.name as string) ||
(e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1),
(e.name as string) || (e.id as string).charAt(0).toUpperCase() + (e.id as string).slice(1),
description: (e.description as string) || '',
icon: SKILL_ICONS[e.id as string],
ignoreInProduction: (e.ignoreInProduction as boolean) ?? false,
hasSetup: !!(setup && setup.required),
hasSetup,
skill_type: (e.skill_type as 'alphahuman' | 'openclaw') ?? 'alphahuman',
};
}
@@ -47,9 +55,7 @@ function SkillTypeBadge({ type }: { type?: string }) {
return (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-md ${
isOpenclaw
? 'bg-sage-500/15 text-sage-400'
: 'bg-primary-500/15 text-primary-400'
isOpenclaw ? 'bg-sage-500/15 text-sage-400' : 'bg-primary-500/15 text-primary-400'
}`}>
{type}
</span>
@@ -149,14 +155,19 @@ export default function SkillsGrid() {
const processed: SkillListEntry[] = manifests
.filter(m => !(m.id as string).includes('_'))
.map(m => {
const setup = m.setup as Record<string, unknown> | undefined;
const setup = m.setup as { required?: boolean; oauth?: unknown } | undefined;
const hasSetup =
!!setup &&
(setup.required === true ||
// OAuth-only skills still need a setup/connect flow
!!setup.oauth);
return {
id: m.id as string,
name: (m.name as string) || (m.id as string),
description: (m.description as string) || '',
icon: SKILL_ICONS[m.id as string],
ignoreInProduction: (m.ignoreInProduction as boolean) ?? false,
hasSetup: !!(setup && setup.required),
hasSetup,
skill_type: 'alphahuman' as const,
};
})
@@ -183,7 +194,6 @@ export default function SkillsGrid() {
};
detectMobile();
refreshSkills();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sort skills by connection status (connected first)
@@ -270,11 +280,7 @@ export default function SkillsGrid() {
}}
className="text-xs text-primary-400 hover:text-primary-300 transition-colors flex items-center gap-1">
{/* Sparkle / robot icon */}
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -296,7 +302,7 @@ export default function SkillsGrid() {
description: 'Auto-generated skill demonstrating the unified registry',
skill_type: 'alphahuman',
tool_code:
"return { message: `Hello from generated skill! args=${JSON.stringify(args)}` };",
'return { message: `Hello from generated skill! args=${JSON.stringify(args)}` };',
},
});
await refreshSkills();
@@ -312,7 +318,11 @@ export default function SkillsGrid() {
<span className="opacity-60">Generating</span>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -387,10 +397,7 @@ export default function SkillsGrid() {
{/* Self-Evolve modal */}
{selfEvolveOpen && (
<SelfEvolveModal
onClose={() => setSelfEvolveOpen(false)}
onSkillCreated={refreshSkills}
/>
<SelfEvolveModal onClose={() => setSelfEvolveOpen(false)} onSkillCreated={refreshSkills} />
)}
{/* Skills Management Modal */}
+237
View File
@@ -0,0 +1,237 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useUser } from '../hooks/useUser';
import { useSkillConnectionStatus } from '../lib/skills/hooks';
import { skillManager } from '../lib/skills/manager';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
import { useAppSelector } from '../store/hooks';
function truncateAddress(address: string): string {
if (!address || address.length < 12) return address;
return `${address.slice(0, 6)}${address.slice(-4)}`;
}
export default function WalletInfoSection() {
const walletStatus = useSkillConnectionStatus('wallet');
const { user } = useUser();
const primaryAddress = useAppSelector(state =>
user?._id ? state.auth.primaryWalletAddressByUser[user._id] : undefined
);
const [networkName, setNetworkName] = useState<string | null>(null);
const [balance, setBalance] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isConnected = walletStatus === 'connected' && !!primaryAddress;
const cancelledRef = useRef(false);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchWalletInfoRef = useRef<(address: string, attempt?: number) => Promise<void>>(null!);
const fetchWalletInfo = useCallback(async (address: string, attempt = 0): Promise<void> => {
if (cancelledRef.current) return;
if (!skillManager.isSkillRunning('wallet')) {
if (attempt < 5 && !cancelledRef.current) {
retryTimerRef.current = setTimeout(
() => fetchWalletInfoRef.current(address, attempt + 1),
1500
);
return;
}
if (!cancelledRef.current) {
setLoading(false);
setNetworkName(null);
setBalance(null);
setError(null);
}
return;
}
try {
// Wallet skill only supports Ethereum Mainnet (chain_id "1")
const listRes = await skillManager.callTool('wallet', 'list_networks', {});
if (cancelledRef.current) return;
const listText = listRes.content?.[0]?.text;
if (!listText || listRes.isError) {
if (!cancelledRef.current) {
setError('Could not load networks');
setLoading(false);
}
return;
}
const listData = JSON.parse(listText) as {
networks?: Array<{ chain_id?: string; name?: string; chain_type?: string }>;
};
const networks = Array.isArray(listData.networks) ? listData.networks : [];
// Prefer Ethereum Mainnet (skill always has it); else first EVM, else first network
const ethMainnet = networks.find(n => n?.chain_id === '1' && n?.chain_type === 'evm');
const firstEvm = networks.find(n => n && n.chain_type === 'evm');
const chosen = ethMainnet ?? firstEvm ?? networks.find(Boolean);
if (!chosen || cancelledRef.current) {
if (!cancelledRef.current) setLoading(false);
return;
}
const networkNameVal = chosen.name ?? chosen.chain_id ?? 'Unknown';
if (!cancelledRef.current) setNetworkName(networkNameVal);
const chainId = chosen.chain_id ?? '';
if (!chainId) {
if (!cancelledRef.current) {
setBalance('—');
setLoading(false);
}
return;
}
const balanceRes = await skillManager.callTool('wallet', 'get_balance', {
address,
chain_id: chainId,
chain_type: chosen.chain_type ?? 'evm',
});
if (cancelledRef.current) return;
const balanceText = balanceRes.content?.[0]?.text;
if (!balanceText || balanceRes.isError) {
if (!cancelledRef.current) {
setBalance('—');
setLoading(false);
}
return;
}
const balanceData = JSON.parse(balanceText) as {
balance_eth?: string;
symbol?: string;
error?: string;
};
if (balanceData.error) {
if (!cancelledRef.current) {
setBalance('—');
setLoading(false);
}
return;
}
const eth = balanceData.balance_eth ?? '0';
const symbol = balanceData.symbol ?? 'ETH';
const parsed = parseFloat(eth);
const value = Number.isFinite(parsed) ? parsed : 0;
const display = value < 0.0001 ? '0' : value.toFixed(4);
if (!cancelledRef.current) {
setBalance(`${display} ${symbol}`);
setLoading(false);
}
} catch (e) {
if (!cancelledRef.current) {
const msg = e instanceof Error ? e.message : String(e);
const isTransient =
msg.includes('not running') || msg.includes('not started') || msg.includes('transport');
if (isTransient && attempt < 3) {
retryTimerRef.current = setTimeout(
() => fetchWalletInfoRef.current(address, attempt + 1),
2000
);
return;
}
console.error('[WalletInfoSection] Failed to load wallet info:', e);
enqueueError({
id: crypto.randomUUID(),
timestamp: Date.now(),
source: 'manual',
title: 'Failed to load wallet info',
message: e instanceof Error ? e.message : String(e),
sentryEvent: buildManualSentryEvent(
{ type: 'WalletInfoLoadError', value: e instanceof Error ? e.message : String(e) },
{ component: 'WalletInfoSection' }
),
originalError: e instanceof Error ? e : new Error(String(e)),
});
setError('Failed to load wallet info');
setBalance(null);
setNetworkName(null);
setLoading(false);
}
}
}, []);
fetchWalletInfoRef.current = fetchWalletInfo;
useEffect(() => {
cancelledRef.current = false;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
if (!isConnected || !primaryAddress) {
setLoading(false);
setNetworkName(null);
setBalance(null);
setError(null);
return;
}
setLoading(true);
setError(null);
fetchWalletInfo(primaryAddress);
return () => {
cancelledRef.current = true;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, [isConnected, primaryAddress, fetchWalletInfo]);
if (!isConnected) return null;
return (
<div className="glass rounded-3xl p-4 shadow-large animate-fade-up mt-4">
<div className="flex items-center gap-2 mb-3">
<svg
className="w-5 h-5 text-primary-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
<span className="font-semibold text-sm">Web3 Wallet</span>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between items-center">
<span className="opacity-70">Address</span>
<span className="font-mono text-xs" title={primaryAddress ?? ''}>
{primaryAddress ? truncateAddress(primaryAddress) : '—'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="opacity-70">Network</span>
<span>
{loading ? (
<span className="opacity-60">Loading</span>
) : error ? (
<span className="text-coral-500 text-xs">{error}</span>
) : (
(networkName ?? '—')
)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="opacity-70">Balance</span>
<span>
{loading ? (
<span className="opacity-60">Loading</span>
) : error ? (
'—'
) : (
(balance ?? '—')
)}
</span>
</div>
</div>
</div>
);
}
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { alphahumanAgentChat } from '../../../utils/tauriCommands';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { alphahumanAgentChat } from '../../../utils/tauriCommands';
type ChatMessage = { role: 'user' | 'agent'; text: string };
@@ -46,12 +46,7 @@ const AgentChatPanel = () => {
}, []);
useEffect(() => {
const payload = {
messages,
providerOverride,
modelOverride,
temperature,
};
const payload = { messages, providerOverride, modelOverride, temperature };
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch {
@@ -65,7 +60,7 @@ const AgentChatPanel = () => {
setError('');
setSending(true);
setInput('');
setMessages((prev) => [...prev, { role: 'user', text }]);
setMessages(prev => [...prev, { role: 'user', text }]);
try {
const response = await alphahumanAgentChat(
text,
@@ -73,7 +68,7 @@ const AgentChatPanel = () => {
modelOverride.trim() ? modelOverride : undefined,
Number.isFinite(Number(temperature)) ? Number(temperature) : undefined
);
setMessages((prev) => [...prev, { role: 'agent', text: response.result }]);
setMessages(prev => [...prev, { role: 'agent', text: response.result }]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
@@ -96,7 +91,7 @@ const AgentChatPanel = () => {
className="input input-bordered w-full text-slate-900 bg-white"
placeholder="openai"
value={providerOverride}
onChange={(event) => setProviderOverride(event.target.value)}
onChange={event => setProviderOverride(event.target.value)}
/>
</label>
<label className="space-y-2 text-sm text-gray-300">
@@ -105,7 +100,7 @@ const AgentChatPanel = () => {
className="input input-bordered w-full text-slate-900 bg-white"
placeholder="gpt-4.1-mini"
value={modelOverride}
onChange={(event) => setModelOverride(event.target.value)}
onChange={event => setModelOverride(event.target.value)}
/>
</label>
<label className="space-y-2 text-sm text-gray-300">
@@ -114,7 +109,7 @@ const AgentChatPanel = () => {
className="input input-bordered w-full text-slate-900 bg-white"
placeholder="0.7"
value={temperature}
onChange={(event) => setTemperature(event.target.value)}
onChange={event => setTemperature(event.target.value)}
/>
</label>
</div>
@@ -139,8 +134,7 @@ const AgentChatPanel = () => {
<div
className={`text-sm whitespace-pre-wrap ${
message.role === 'user' ? 'text-white' : 'text-emerald-200'
}`}
>
}`}>
{message.text}
</div>
</div>
@@ -151,7 +145,7 @@ const AgentChatPanel = () => {
className="textarea textarea-bordered w-full min-h-[140px] text-slate-900 bg-white"
placeholder="Ask the agent anything..."
value={input}
onChange={(event) => setInput(event.target.value)}
onChange={event => setInput(event.target.value)}
/>
<button className="btn btn-primary" onClick={sendMessage} disabled={sending}>
{sending ? 'Sending…' : 'Send Message'}
+17 -29
View File
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import {
alphahumanListIntegrations,
alphahumanGetConfig,
alphahumanGetRuntimeFlags,
alphahumanListIntegrations,
alphahumanSetBrowserAllowAll,
alphahumanUpdateBrowserSettings,
type IntegrationCategory,
@@ -50,7 +50,7 @@ const SkillsPanel = () => {
const runtimeFlags = await alphahumanGetRuntimeFlags();
setBrowserAllowAll(runtimeFlags.result.browser_allow_all);
const entries = await Promise.all(
response.result.map(async (integration) => {
response.result.map(async integration => {
const skillId = integrationSkillId(integration);
try {
const enabled =
@@ -99,7 +99,8 @@ const SkillsPanel = () => {
<div>
<h3 className="text-lg font-semibold text-white">Browser Access</h3>
<p className="text-xs text-stone-400">
Allow the browser tool to visit any public domain (private and file URLs are still blocked).
Allow the browser tool to visit any public domain (private and file URLs are still
blocked).
</p>
</div>
<label className="flex items-center gap-3 text-sm text-stone-300">
@@ -108,7 +109,7 @@ const SkillsPanel = () => {
className="checkbox checkbox-primary"
checked={browserAllowAll}
disabled={browserAllowAllBusy}
onChange={async (event) => {
onChange={async event => {
const next = event.target.checked;
setBrowserAllowAllBusy(true);
try {
@@ -136,9 +137,7 @@ const SkillsPanel = () => {
</div>
)}
<div className="rounded-xl border border-stone-800/60 bg-black/40">
{loading && (
<div className="p-4 text-sm text-stone-400">Loading integrations...</div>
)}
{loading && <div className="p-4 text-sm text-stone-400">Loading integrations...</div>}
{!loading && integrations.length === 0 && (
<div className="p-4 text-sm text-stone-400">
No integrations registered in Alphahuman.
@@ -159,9 +158,9 @@ const SkillsPanel = () => {
enabled={enabledMap[integration.name] ?? false}
busy={toggleBusy[integration.name] ?? false}
toggleable={isIntegrationToggleable(integration)}
onToggle={async (nextEnabled) => {
onToggle={async nextEnabled => {
const key = integration.name;
setToggleBusy((prev) => ({ ...prev, [key]: true }));
setToggleBusy(prev => ({ ...prev, [key]: true }));
try {
if (integration.name === 'Browser') {
await alphahumanUpdateBrowserSettings({ enabled: nextEnabled });
@@ -173,12 +172,12 @@ const SkillsPanel = () => {
await runtimeDisableSkill(skillId);
}
}
setEnabledMap((prev) => ({ ...prev, [key]: nextEnabled }));
setEnabledMap(prev => ({ ...prev, [key]: nextEnabled }));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
} finally {
setToggleBusy((prev) => ({ ...prev, [key]: false }));
setToggleBusy(prev => ({ ...prev, [key]: false }));
}
}}
/>
@@ -218,31 +217,23 @@ function IntegrationRow({
<div
className={`flex items-center justify-between gap-4 p-4 ${
isLast ? '' : 'border-b border-stone-800/60'
}`}
>
}`}>
<div className="flex items-center gap-3 text-left flex-1 min-w-0">
<div className="w-6 h-6 flex items-center justify-center text-white/70">
<span className="text-xs font-semibold uppercase">
{integration.name.slice(0, 2)}
</span>
<span className="text-xs font-semibold uppercase">{integration.name.slice(0, 2)}</span>
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-white truncate">{integration.name}</div>
<div className="text-xs text-stone-400 line-clamp-2">
{integration.description}
</div>
<div className="text-xs text-stone-400 line-clamp-2">{integration.description}</div>
{integration.setup_hints.length > 0 && (
<div className="mt-1 text-[11px] text-stone-500">
{integration.setup_hints[0]}
</div>
<div className="mt-1 text-[11px] text-stone-500">{integration.setup_hints[0]}</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
<span
className={`px-2 py-1 text-[11px] font-semibold uppercase border rounded-full ${statusStyle}`}
>
className={`px-2 py-1 text-[11px] font-semibold uppercase border rounded-full ${statusStyle}`}>
{integration.status}
</span>
<label className="flex items-center gap-2 text-xs text-stone-300">
@@ -251,7 +242,7 @@ function IntegrationRow({
className="checkbox checkbox-primary"
checked={enabled}
disabled={busy || !toggleable}
onChange={(event) => onToggle(event.target.checked)}
onChange={event => onToggle(event.target.checked)}
/>
{busy ? 'Saving…' : enabled ? 'Enabled' : toggleable ? 'Disabled' : 'Managed'}
</label>
@@ -274,8 +265,5 @@ function isIntegrationToggleable(integration: IntegrationInfo): boolean {
if (integration.name === 'Browser') {
return true;
}
return (
integration.category === 'Chat' ||
integration.category === 'ToolsAutomation'
);
return integration.category === 'Chat' || integration.category === 'ToolsAutomation';
}
@@ -7,16 +7,9 @@ import {
ShieldCheckIcon,
WrenchScrewdriverIcon,
} from '@heroicons/react/24/outline';
import {useCallback, useEffect, useMemo, useState} from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import SectionCard from './components/SectionCard';
import InputGroup, { Field, CheckboxField } from './components/InputGroup';
import ValidatedField, { ValidatedSelect } from './components/ValidatedField';
import ActionPanel, { PrimaryButton } from './components/ActionPanel';
import DaemonHealthIndicator from '../../daemon/DaemonHealthIndicator';
import { useDaemonHealth, formatRelativeTime } from '../../../hooks/useDaemonHealth';
import { formatRelativeTime, useDaemonHealth } from '../../../hooks/useDaemonHealth';
import {
alphahumanAgentChat,
alphahumanDecryptSecret,
@@ -46,6 +39,13 @@ import {
SkillSnapshot,
TunnelConfig,
} from '../../../utils/tauriCommands';
import DaemonHealthIndicator from '../../daemon/DaemonHealthIndicator';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import ActionPanel, { PrimaryButton } from './components/ActionPanel';
import InputGroup, { CheckboxField, Field } from './components/InputGroup';
import SectionCard from './components/SectionCard';
import ValidatedField, { ValidatedSelect } from './components/ValidatedField';
const formatJson = (value: unknown) => JSON.stringify(value, null, 2);
@@ -126,48 +126,58 @@ const TauriCommandsPanel = () => {
};
// Provider configurations for smart defaults
const providerConfigs = useMemo(() => ({
openai: {
defaultUrl: 'https://api.openai.com/v1',
keyPattern: /^sk-[a-zA-Z0-9]{32,}$/,
models: ['gpt-4', 'gpt-4-turbo', 'gpt-4o', 'gpt-3.5-turbo']
},
anthropic: {
defaultUrl: 'https://api.anthropic.com',
keyPattern: /^sk-ant-[a-zA-Z0-9_-]{32,}$/,
models: ['claude-sonnet-4-5-20250929', 'claude-opus-4-6', 'claude-haiku-3-5']
},
ollama: {
defaultUrl: 'http://localhost:11434',
keyPattern: null, // No API key required
models: ['llama3', 'llama3:8b', 'codellama', 'mistral', 'phi3']
},
groq: {
defaultUrl: 'https://api.groq.com/openai/v1',
keyPattern: /^gsk_[a-zA-Z0-9]{32,}$/,
models: ['llama-3.1-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768']
},
cohere: {
defaultUrl: 'https://api.cohere.ai/v1',
keyPattern: /^[a-zA-Z0-9]{32,}$/,
models: ['command-r', 'command-r-plus', 'command-light']
}
}), []);
const providerConfigs = useMemo(
() => ({
openai: {
defaultUrl: 'https://api.openai.com/v1',
keyPattern: /^sk-[a-zA-Z0-9]{32,}$/,
models: ['gpt-4', 'gpt-4-turbo', 'gpt-4o', 'gpt-3.5-turbo'],
},
anthropic: {
defaultUrl: 'https://api.anthropic.com',
keyPattern: /^sk-ant-[a-zA-Z0-9_-]{32,}$/,
models: ['claude-sonnet-4-5-20250929', 'claude-opus-4-6', 'claude-haiku-3-5'],
},
ollama: {
defaultUrl: 'http://localhost:11434',
keyPattern: null, // No API key required
models: ['llama3', 'llama3:8b', 'codellama', 'mistral', 'phi3'],
},
groq: {
defaultUrl: 'https://api.groq.com/openai/v1',
keyPattern: /^gsk_[a-zA-Z0-9]{32,}$/,
models: ['llama-3.1-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'],
},
cohere: {
defaultUrl: 'https://api.cohere.ai/v1',
keyPattern: /^[a-zA-Z0-9]{32,}$/,
models: ['command-r', 'command-r-plus', 'command-light'],
},
}),
[]
);
// Validation functions
const validateApiKey = useCallback((key: string, provider: string): string | null => {
if (!provider || provider === 'none') return null;
if (!key.trim() && provider && provider !== 'none' && provider !== 'ollama') {
return 'API key is required for this provider';
}
if (key.trim() && provider && providerConfigs[provider as keyof typeof providerConfigs]?.keyPattern) {
const pattern = providerConfigs[provider as keyof typeof providerConfigs].keyPattern;
if (pattern && !pattern.test(key)) {
return `Invalid API key format for ${provider}`;
const validateApiKey = useCallback(
(key: string, provider: string): string | null => {
if (!provider || provider === 'none') return null;
if (!key.trim() && provider && provider !== 'none' && provider !== 'ollama') {
return 'API key is required for this provider';
}
}
return null;
}, [providerConfigs]);
if (
key.trim() &&
provider &&
providerConfigs[provider as keyof typeof providerConfigs]?.keyPattern
) {
const pattern = providerConfigs[provider as keyof typeof providerConfigs].keyPattern;
if (pattern && !pattern.test(key)) {
return `Invalid API key format for ${provider}`;
}
}
return null;
},
[providerConfigs]
);
const validateApiUrl = useCallback((url: string): string | null => {
if (!url.trim()) return null;
@@ -176,7 +186,11 @@ const TauriCommandsPanel = () => {
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return 'URL must use HTTP or HTTPS protocol';
}
if (parsedUrl.protocol === 'http:' && !parsedUrl.hostname.includes('localhost') && !parsedUrl.hostname.includes('127.0.0.1')) {
if (
parsedUrl.protocol === 'http:' &&
!parsedUrl.hostname.includes('localhost') &&
!parsedUrl.hostname.includes('127.0.0.1')
) {
return 'HTTP URLs are only allowed for localhost';
}
return null;
@@ -185,23 +199,29 @@ const TauriCommandsPanel = () => {
}
}, []);
const validateProvider = useCallback((provider: string): string | null => {
if (!provider.trim()) return null;
const supportedProviders = Object.keys(providerConfigs);
if (!supportedProviders.includes(provider)) {
return `Unsupported provider. Supported: ${supportedProviders.join(', ')}`;
}
return null;
}, [providerConfigs]);
const validateProvider = useCallback(
(provider: string): string | null => {
if (!provider.trim()) return null;
const supportedProviders = Object.keys(providerConfigs);
if (!supportedProviders.includes(provider)) {
return `Unsupported provider. Supported: ${supportedProviders.join(', ')}`;
}
return null;
},
[providerConfigs]
);
const validateModel = useCallback((model: string, provider: string): string | null => {
if (!model.trim() || !provider.trim()) return null;
const config = providerConfigs[provider as keyof typeof providerConfigs];
if (config && !config.models.includes(model)) {
return `Model not available for ${provider}. Try: ${config.models.slice(0, 3).join(', ')}`;
}
return null;
}, [providerConfigs]);
const validateModel = useCallback(
(model: string, provider: string): string | null => {
if (!model.trim() || !provider.trim()) return null;
const config = providerConfigs[provider as keyof typeof providerConfigs];
if (config && !config.models.includes(model)) {
return `Model not available for ${provider}. Try: ${config.models.slice(0, 3).join(', ')}`;
}
return null;
},
[providerConfigs]
);
const validateTemperature = useCallback((temp: string): string | null => {
if (!temp.trim()) return null;
@@ -232,7 +252,18 @@ const TauriCommandsPanel = () => {
setFieldErrors(errors);
return Object.keys(errors).length === 0;
}, [apiKey, apiUrl, defaultProvider, defaultModel, defaultTemp, validateApiKey, validateApiUrl, validateProvider, validateModel, validateTemperature]);
}, [
apiKey,
apiUrl,
defaultProvider,
defaultModel,
defaultTemp,
validateApiKey,
validateApiUrl,
validateProvider,
validateModel,
validateTemperature,
]);
// Format timestamp for display
const formatTime = useCallback((date: Date): string => {
@@ -256,11 +287,24 @@ const TauriCommandsPanel = () => {
// Perform validation on changes
performValidation();
}, [apiKey, apiUrl, defaultProvider, defaultModel, defaultTemp, originalConfig, configLoaded, performValidation]);
}, [
apiKey,
apiUrl,
defaultProvider,
defaultModel,
defaultTemp,
originalConfig,
configLoaded,
performValidation,
]);
// Auto-populate URL based on provider
useEffect(() => {
if (defaultProvider && !apiUrl && providerConfigs[defaultProvider as keyof typeof providerConfigs]) {
if (
defaultProvider &&
!apiUrl &&
providerConfigs[defaultProvider as keyof typeof providerConfigs]
) {
const config = providerConfigs[defaultProvider as keyof typeof providerConfigs];
setApiUrl(config.defaultUrl);
}
@@ -419,15 +463,20 @@ const TauriCommandsPanel = () => {
default_model: defaultModel,
default_temperature: defaultTemp,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('API key')) {
setFieldErrors(prev => ({ ...prev, apiKey: 'Invalid API key or authentication failed' }));
} else if (message.includes('provider')) {
setFieldErrors(prev => ({ ...prev, defaultProvider: 'Provider not supported or misconfigured' }));
setFieldErrors(prev => ({
...prev,
defaultProvider: 'Provider not supported or misconfigured',
}));
} else if (message.includes('model')) {
setFieldErrors(prev => ({ ...prev, defaultModel: 'Model not available for this provider' }));
setFieldErrors(prev => ({
...prev,
defaultModel: 'Model not available for this provider',
}));
}
setError(message);
} finally {
@@ -464,7 +513,7 @@ const TauriCommandsPanel = () => {
// Test connection by attempting to refresh models with current settings
const result = await Promise.race([
alphahumanModelsRefresh(defaultProvider, false),
timeoutPromise
timeoutPromise,
]);
setOutput(formatJson(result));
@@ -480,7 +529,6 @@ const TauriCommandsPanel = () => {
delete newErrors.defaultProvider;
return newErrors;
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error('Test connection error:', err);
@@ -491,9 +539,15 @@ const TauriCommandsPanel = () => {
} else if (message.includes('provider') || message.includes('404')) {
setFieldErrors(prev => ({ ...prev, defaultProvider: 'Provider not found or unavailable' }));
} else if (message.includes('network') || message.includes('timeout')) {
setFieldErrors(prev => ({ ...prev, apiUrl: 'Network error - check API URL and connectivity' }));
setFieldErrors(prev => ({
...prev,
apiUrl: 'Network error - check API URL and connectivity',
}));
} else if (message.includes('Not running in Tauri')) {
setFieldErrors(prev => ({ ...prev, defaultProvider: 'Desktop application required for testing' }));
setFieldErrors(prev => ({
...prev,
defaultProvider: 'Desktop application required for testing',
}));
}
setError(`Connection test failed: ${message}`);
@@ -646,153 +700,210 @@ const TauriCommandsPanel = () => {
collapsible={true}
defaultExpanded={!isCollapsed('system-configuration')}
hasChanges={hasUnsavedChanges}
loading={operationLoading === 'loadConfig' || operationLoading === 'saveModelSettings' || validationLoading}
>
<InputGroup
title="Model API Keys"
description="Configure your AI model provider settings with real-time validation"
>
<ValidatedSelect
label="Default Provider"
value={defaultProvider}
onChange={(value) => {
setDefaultProvider(value);
// Auto-populate URL when provider changes
if (value && providerConfigs[value as keyof typeof providerConfigs]) {
const config = providerConfigs[value as keyof typeof providerConfigs];
if (!apiUrl || Object.values(providerConfigs).some(c => c.defaultUrl === apiUrl)) {
setApiUrl(config.defaultUrl);
loading={
operationLoading === 'loadConfig' ||
operationLoading === 'saveModelSettings' ||
validationLoading
}>
<InputGroup
title="Model API Keys"
description="Configure your AI model provider settings with real-time validation">
<ValidatedSelect
label="Default Provider"
value={defaultProvider}
onChange={value => {
setDefaultProvider(value);
// Auto-populate URL when provider changes
if (value && providerConfigs[value as keyof typeof providerConfigs]) {
const config = providerConfigs[value as keyof typeof providerConfigs];
if (
!apiUrl ||
Object.values(providerConfigs).some(c => c.defaultUrl === apiUrl)
) {
setApiUrl(config.defaultUrl);
}
}
}}
options={[
{
value: '',
label: 'Select provider...',
description: 'Choose your AI service provider',
},
{
value: 'openai',
label: 'OpenAI',
description: 'GPT models with high performance',
},
{
value: 'anthropic',
label: 'Anthropic',
description: 'Claude models with safety focus',
},
{
value: 'ollama',
label: 'Ollama',
description: 'Local models, no API key needed',
},
{
value: 'groq',
label: 'Groq',
description: 'Fast inference with LPU acceleration',
},
{
value: 'cohere',
label: 'Cohere',
description: 'Enterprise-grade language models',
},
]}
error={fieldErrors.defaultProvider}
required={true}
helpText="Primary AI provider for agent operations. Each provider offers different models with unique capabilities and pricing."
/>
<ValidatedField
label="API Key"
value={apiKey}
onChange={setApiKey}
error={fieldErrors.apiKey}
required={!!defaultProvider && defaultProvider !== 'ollama'}
type="password"
placeholder={
defaultProvider === 'openai'
? 'sk-...'
: defaultProvider === 'anthropic'
? 'sk-ant-...'
: defaultProvider === 'groq'
? 'gsk_...'
: defaultProvider === 'ollama'
? 'Not required for Ollama'
: 'Enter your API key...'
}
}}
options={[
{ value: '', label: 'Select provider...', description: 'Choose your AI service provider' },
{ value: 'openai', label: 'OpenAI', description: 'GPT models with high performance' },
{ value: 'anthropic', label: 'Anthropic', description: 'Claude models with safety focus' },
{ value: 'ollama', label: 'Ollama', description: 'Local models, no API key needed' },
{ value: 'groq', label: 'Groq', description: 'Fast inference with LPU acceleration' },
{ value: 'cohere', label: 'Cohere', description: 'Enterprise-grade language models' },
]}
error={fieldErrors.defaultProvider}
required={true}
helpText="Primary AI provider for agent operations. Each provider offers different models with unique capabilities and pricing."
/>
helpText={
defaultProvider === 'ollama'
? 'API key not required for Ollama (local models)'
: "Enter your AI provider's API key for authentication. Keep this secure and never share it publicly."
}
validation={
!apiKey
? 'none'
: fieldErrors.apiKey
? 'invalid'
: defaultProvider && validateApiKey(apiKey, defaultProvider) === null
? 'valid'
: 'none'
}
disabled={defaultProvider === 'ollama'}
/>
<ValidatedField
label="API Key"
value={apiKey}
onChange={setApiKey}
error={fieldErrors.apiKey}
required={!!defaultProvider && defaultProvider !== 'ollama'}
type="password"
placeholder={
defaultProvider === 'openai' ? 'sk-...' :
defaultProvider === 'anthropic' ? 'sk-ant-...' :
defaultProvider === 'groq' ? 'gsk_...' :
defaultProvider === 'ollama' ? 'Not required for Ollama' :
'Enter your API key...'
}
helpText={
defaultProvider === 'ollama'
? 'API key not required for Ollama (local models)'
: 'Enter your AI provider\'s API key for authentication. Keep this secure and never share it publicly.'
}
validation={
!apiKey ? 'none' :
fieldErrors.apiKey ? 'invalid' :
defaultProvider && validateApiKey(apiKey, defaultProvider) === null ? 'valid' : 'none'
}
disabled={defaultProvider === 'ollama'}
/>
<ValidatedField
label="API Base URL"
value={apiUrl}
onChange={setApiUrl}
error={fieldErrors.apiUrl}
type="url"
placeholder="https://api.openai.com/v1"
helpText="Custom API endpoint for your AI provider. Auto-populated when you select a provider. Change only for custom deployments or proxy servers."
validation={
!apiUrl
? 'none'
: fieldErrors.apiUrl
? 'invalid'
: validateApiUrl(apiUrl) === null
? 'valid'
: 'none'
}
/>
<ValidatedField
label="API Base URL"
value={apiUrl}
onChange={setApiUrl}
error={fieldErrors.apiUrl}
type="url"
placeholder="https://api.openai.com/v1"
helpText="Custom API endpoint for your AI provider. Auto-populated when you select a provider. Change only for custom deployments or proxy servers."
validation={
!apiUrl ? 'none' :
fieldErrors.apiUrl ? 'invalid' :
validateApiUrl(apiUrl) === null ? 'valid' : 'none'
}
/>
<ValidatedSelect
label="Default Model"
value={defaultModel}
onChange={setDefaultModel}
options={[
{
value: '',
label: 'Select model...',
description: 'Choose specific model for this provider',
},
...((defaultProvider &&
providerConfigs[defaultProvider as keyof typeof providerConfigs]?.models.map(
model => ({
value: model,
label: model,
description: model.includes('gpt-4')
? 'Advanced reasoning, higher cost'
: model.includes('gpt-3.5')
? 'Fast and economical'
: model.includes('claude')
? 'Excellent analysis and safety'
: model.includes('llama')
? 'Open source, good performance'
: model.includes('mixtral')
? 'Mixture of experts model'
: 'High-quality language model',
})
)) ||
[]),
]}
error={fieldErrors.defaultModel}
helpText="Primary language model for agent interactions. Available models are filtered based on your selected provider."
disabled={!defaultProvider}
validation={
!defaultModel
? 'none'
: fieldErrors.defaultModel
? 'invalid'
: defaultProvider && validateModel(defaultModel, defaultProvider) === null
? 'valid'
: 'none'
}
/>
<ValidatedSelect
label="Default Model"
value={defaultModel}
onChange={setDefaultModel}
options={[
{ value: '', label: 'Select model...', description: 'Choose specific model for this provider' },
...(defaultProvider && providerConfigs[defaultProvider as keyof typeof providerConfigs]?.models.map(model => ({
value: model,
label: model,
description:
model.includes('gpt-4') ? 'Advanced reasoning, higher cost' :
model.includes('gpt-3.5') ? 'Fast and economical' :
model.includes('claude') ? 'Excellent analysis and safety' :
model.includes('llama') ? 'Open source, good performance' :
model.includes('mixtral') ? 'Mixture of experts model' :
'High-quality language model'
})) || [])
]}
error={fieldErrors.defaultModel}
helpText="Primary language model for agent interactions. Available models are filtered based on your selected provider."
disabled={!defaultProvider}
validation={
!defaultModel ? 'none' :
fieldErrors.defaultModel ? 'invalid' :
defaultProvider && validateModel(defaultModel, defaultProvider) === null ? 'valid' : 'none'
}
/>
<ValidatedField
label="Temperature"
value={defaultTemp}
onChange={setDefaultTemp}
error={fieldErrors.defaultTemp}
type="number"
placeholder="0.7"
helpText="Controls randomness in AI responses (0.0-2.0). Lower values (0.1-0.3) for factual tasks, medium (0.5-0.8) for balanced responses, higher (0.8-1.5) for creative tasks."
validation={
!defaultTemp
? 'none'
: fieldErrors.defaultTemp
? 'invalid'
: validateTemperature(defaultTemp) === null
? 'valid'
: 'none'
}
/>
</InputGroup>
<ValidatedField
label="Temperature"
value={defaultTemp}
onChange={setDefaultTemp}
error={fieldErrors.defaultTemp}
type="number"
placeholder="0.7"
helpText="Controls randomness in AI responses (0.0-2.0). Lower values (0.1-0.3) for factual tasks, medium (0.5-0.8) for balanced responses, higher (0.8-1.5) for creative tasks."
validation={
!defaultTemp ? 'none' :
fieldErrors.defaultTemp ? 'invalid' :
validateTemperature(defaultTemp) === null ? 'valid' : 'none'
}
/>
</InputGroup>
<ActionPanel
hasChanges={hasUnsavedChanges}
success={lastSaveTime ? `Settings saved at ${formatTime(lastSaveTime)}` : false}
error={Object.values(fieldErrors).find(Boolean)}
>
<PrimaryButton
onClick={loadConfig}
loading={operationLoading === 'loadConfig'}
variant="outline"
>
Load Config
</PrimaryButton>
<PrimaryButton
onClick={testConnection}
loading={validationLoading}
disabled={!defaultProvider || (!apiKey && defaultProvider !== 'ollama')}
variant="outline"
>
Test Connection
</PrimaryButton>
<PrimaryButton
onClick={saveModelSettings}
loading={operationLoading === 'saveModelSettings'}
disabled={Object.keys(fieldErrors).length > 0 || !hasUnsavedChanges}
>
Save Settings
</PrimaryButton>
</ActionPanel>
</SectionCard>
<ActionPanel
hasChanges={hasUnsavedChanges}
success={lastSaveTime ? `Settings saved at ${formatTime(lastSaveTime)}` : false}
error={Object.values(fieldErrors).find(Boolean)}>
<PrimaryButton
onClick={loadConfig}
loading={operationLoading === 'loadConfig'}
variant="outline">
Load Config
</PrimaryButton>
<PrimaryButton
onClick={testConnection}
loading={validationLoading}
disabled={!defaultProvider || (!apiKey && defaultProvider !== 'ollama')}
variant="outline">
Test Connection
</PrimaryButton>
<PrimaryButton
onClick={saveModelSettings}
loading={operationLoading === 'saveModelSettings'}
disabled={Object.keys(fieldErrors).length > 0 || !hasUnsavedChanges}>
Save Settings
</PrimaryButton>
</ActionPanel>
</SectionCard>
)}
{/* Category 2: Runtime & Execution */}
@@ -863,123 +974,134 @@ const TauriCommandsPanel = () => {
</div>
)}
<InputGroup title="Daemon Service Management">
<div className="md:col-span-2">
<div className="space-y-4">
{/* Live Status Display */}
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-900/40 border border-stone-800/60">
<div className="flex items-center gap-3">
<DaemonHealthIndicator size="md" />
<div>
<div className="text-white font-medium">Daemon Status: {daemonHealth.status}</div>
<div className="text-xs text-gray-400">
Last update: {daemonHealth.lastUpdate ? formatRelativeTime(daemonHealth.lastUpdate) : 'Never'}
</div>
{daemonHealth.healthSnapshot && (
<div className="text-xs text-gray-500">
PID: {daemonHealth.healthSnapshot.pid} Uptime: {daemonHealth.uptimeText}
<InputGroup title="Daemon Service Management">
<div className="md:col-span-2">
<div className="space-y-4">
{/* Live Status Display */}
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-900/40 border border-stone-800/60">
<div className="flex items-center gap-3">
<DaemonHealthIndicator size="md" />
<div>
<div className="text-white font-medium">
Daemon Status: {daemonHealth.status}
</div>
)}
</div>
</div>
{daemonHealth.status === 'error' && (
<PrimaryButton
onClick={() => daemonHealth.restartDaemon()}
variant="outline"
loading={daemonHealth.isRecovering}
>
Restart
</PrimaryButton>
)}
</div>
{/* Component Health */}
{daemonHealth.componentCount > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
{Object.entries(daemonHealth.components).map(([name, health]) => (
<div key={name} className="flex items-center gap-2 p-2 rounded bg-stone-800/40">
<div className={`w-2 h-2 rounded-full ${
health.status === 'ok' ? 'bg-green-500' :
health.status === 'starting' ? 'bg-yellow-500' : 'bg-red-500'
}`} />
<span className="capitalize text-gray-300">{name}</span>
{health.restart_count > 0 && (
<span className="text-xs text-yellow-400">({health.restart_count})</span>
<div className="text-xs text-gray-400">
Last update:{' '}
{daemonHealth.lastUpdate
? formatRelativeTime(daemonHealth.lastUpdate)
: 'Never'}
</div>
{daemonHealth.healthSnapshot && (
<div className="text-xs text-gray-500">
PID: {daemonHealth.healthSnapshot.pid} Uptime:{' '}
{daemonHealth.uptimeText}
</div>
)}
</div>
))}
</div>
)}
{/* Service Controls */}
<ActionPanel>
<PrimaryButton
onClick={() => daemonHealth.startDaemon()}
loading={operationLoading === 'serviceStart'}
disabled={daemonHealth.status === 'running'}
>
Start
</PrimaryButton>
<PrimaryButton
onClick={() => daemonHealth.stopDaemon()}
loading={operationLoading === 'serviceStop'}
disabled={daemonHealth.status === 'disconnected'}
variant="outline"
>
Stop
</PrimaryButton>
<PrimaryButton
onClick={() => run(alphahumanServiceStatus, 'serviceStatus')}
loading={operationLoading === 'serviceStatus'}
variant="outline"
>
Status
</PrimaryButton>
<PrimaryButton
onClick={() => run(alphahumanServiceInstall, 'serviceInstall')}
loading={operationLoading === 'serviceInstall'}
variant="outline"
>
Install
</PrimaryButton>
<PrimaryButton
onClick={() => run(alphahumanServiceUninstall, 'serviceUninstall')}
loading={operationLoading === 'serviceUninstall'}
variant="outline"
>
Uninstall
</PrimaryButton>
</ActionPanel>
{/* Auto-start Toggle */}
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-800/40 border border-stone-700/60">
<div>
<div className="text-sm font-medium text-gray-300">Auto-start Daemon</div>
<div className="text-xs text-gray-500">Automatically start daemon on app launch</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={daemonHealth.isAutoStartEnabled}
onChange={(e) => daemonHealth.setAutoStart(e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{/* Connection Info */}
{daemonHealth.connectionAttempts > 0 && (
<div className="p-3 rounded-lg bg-yellow-900/20 border border-yellow-500/30">
<div className="text-sm text-yellow-400">
Connection attempts: {daemonHealth.connectionAttempts}
</div>
{daemonHealth.status === 'error' && (
<PrimaryButton
onClick={() => daemonHealth.restartDaemon()}
variant="outline"
loading={daemonHealth.isRecovering}>
Restart
</PrimaryButton>
)}
</div>
)}
{/* Component Health */}
{daemonHealth.componentCount > 0 && (
<div className="grid grid-cols-2 gap-2 text-sm">
{Object.entries(daemonHealth.components).map(([name, health]) => (
<div
key={name}
className="flex items-center gap-2 p-2 rounded bg-stone-800/40">
<div
className={`w-2 h-2 rounded-full ${
health.status === 'ok'
? 'bg-green-500'
: health.status === 'starting'
? 'bg-yellow-500'
: 'bg-red-500'
}`}
/>
<span className="capitalize text-gray-300">{name}</span>
{health.restart_count > 0 && (
<span className="text-xs text-yellow-400">
({health.restart_count})
</span>
)}
</div>
))}
</div>
)}
{/* Service Controls */}
<ActionPanel>
<PrimaryButton
onClick={() => daemonHealth.startDaemon()}
loading={operationLoading === 'serviceStart'}
disabled={daemonHealth.status === 'running'}>
Start
</PrimaryButton>
<PrimaryButton
onClick={() => daemonHealth.stopDaemon()}
loading={operationLoading === 'serviceStop'}
disabled={daemonHealth.status === 'disconnected'}
variant="outline">
Stop
</PrimaryButton>
<PrimaryButton
onClick={() => run(alphahumanServiceStatus, 'serviceStatus')}
loading={operationLoading === 'serviceStatus'}
variant="outline">
Status
</PrimaryButton>
<PrimaryButton
onClick={() => run(alphahumanServiceInstall, 'serviceInstall')}
loading={operationLoading === 'serviceInstall'}
variant="outline">
Install
</PrimaryButton>
<PrimaryButton
onClick={() => run(alphahumanServiceUninstall, 'serviceUninstall')}
loading={operationLoading === 'serviceUninstall'}
variant="outline">
Uninstall
</PrimaryButton>
</ActionPanel>
{/* Auto-start Toggle */}
<div className="flex items-center justify-between p-3 rounded-lg bg-stone-800/40 border border-stone-700/60">
<div>
<div className="text-sm font-medium text-gray-300">Auto-start Daemon</div>
<div className="text-xs text-gray-500">
Automatically start daemon on app launch
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={daemonHealth.isAutoStartEnabled}
onChange={e => daemonHealth.setAutoStart(e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
{/* Connection Info */}
{daemonHealth.connectionAttempts > 0 && (
<div className="p-3 rounded-lg bg-yellow-900/20 border border-yellow-500/30">
<div className="text-sm text-yellow-400">
Connection attempts: {daemonHealth.connectionAttempts}
</div>
</div>
)}
</div>
</div>
</div>
</InputGroup>
</SectionCard>
</InputGroup>
</SectionCard>
)}
</div>
@@ -1,5 +1,5 @@
import React from 'react';
import { CheckIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
import React from 'react';
interface ActionPanelProps {
children: React.ReactNode;
@@ -14,7 +14,7 @@ const ActionPanel: React.FC<ActionPanelProps> = ({
hasChanges = false,
success = false,
error,
className = ''
className = '',
}) => {
return (
<div className={`space-y-6 ${className}`}>
@@ -60,21 +60,24 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
disabled = false,
variant = 'primary',
children,
className = ''
className = '',
}) => {
const baseClasses = 'px-6 py-3 rounded-lg font-medium transition-all duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
const baseClasses =
'px-6 py-3 rounded-lg font-medium transition-all duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-primary-600 hover:bg-primary-500 active:bg-primary-700 text-white shadow-soft hover:shadow-lg hover:shadow-primary-500/25 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black',
secondary: 'bg-stone-800 hover:bg-stone-700 active:bg-stone-600 text-white border border-stone-600 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black',
outline: 'border border-stone-600 text-white hover:bg-white/10 active:bg-white/20 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black'
primary:
'bg-primary-600 hover:bg-primary-500 active:bg-primary-700 text-white shadow-soft hover:shadow-lg hover:shadow-primary-500/25 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black',
secondary:
'bg-stone-800 hover:bg-stone-700 active:bg-stone-600 text-white border border-stone-600 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black',
outline:
'border border-stone-600 text-white hover:bg-white/10 active:bg-white/20 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black',
};
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${className} flex items-center justify-center`}
onClick={onClick}
disabled={disabled || loading}
>
disabled={disabled || loading}>
<div className="flex items-center gap-2">
{loading && (
<div className="h-4 w-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
@@ -86,4 +89,4 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
};
export default ActionPanel;
export { PrimaryButton };
export { PrimaryButton };
@@ -11,23 +11,19 @@ const InputGroup: React.FC<InputGroupProps> = ({
title,
description,
children,
className = ''
className = '',
}) => {
return (
<div className={`space-y-6 ${className}`}>
{title && (
<div className="space-y-2">
<h4 className="text-lg font-medium text-white">{title}</h4>
{description && (
<p className="text-sm text-gray-400">{description}</p>
)}
{description && <p className="text-sm text-gray-400">{description}</p>}
</div>
)}
<div className="rounded-lg border border-white/10 bg-white/5 backdrop-blur-sm p-6">
<div className="grid gap-6 md:grid-cols-2">
{children}
</div>
<div className="grid gap-6 md:grid-cols-2">{children}</div>
</div>
</div>
);
@@ -46,17 +42,14 @@ const Field: React.FC<FieldProps> = ({
children,
helpText,
className = '',
fullWidth = false
fullWidth = false,
}) => {
return (
<label className={`space-y-3 text-sm text-gray-300 ${fullWidth ? 'md:col-span-2' : ''} ${className}`}>
<label
className={`space-y-3 text-sm text-gray-300 ${fullWidth ? 'md:col-span-2' : ''} ${className}`}>
<div>
<span className="font-medium">{label}</span>
{helpText && (
<p className="text-xs text-gray-400 leading-relaxed mt-1">
{helpText}
</p>
)}
{helpText && <p className="text-xs text-gray-400 leading-relaxed mt-1">{helpText}</p>}
</div>
{children}
</label>
@@ -76,7 +69,7 @@ const CheckboxField: React.FC<CheckboxFieldProps> = ({
checked,
onChange,
helpText,
className = ''
className = '',
}) => {
return (
<div className={`flex flex-col gap-3 ${className}`}>
@@ -85,18 +78,14 @@ const CheckboxField: React.FC<CheckboxFieldProps> = ({
type="checkbox"
className="w-5 h-5 rounded border-2 border-stone-600 bg-stone-900/40 text-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500/50 transition-all duration-200"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
onChange={e => onChange(e.target.checked)}
/>
<span className="font-medium">{label}</span>
</label>
{helpText && (
<p className="text-xs text-gray-400 ml-7 leading-relaxed">
{helpText}
</p>
)}
{helpText && <p className="text-xs text-gray-400 ml-7 leading-relaxed">{helpText}</p>}
</div>
);
};
export default InputGroup;
export { Field, CheckboxField };
export { Field, CheckboxField };
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import React, { useState } from 'react';
interface SectionCardProps {
title: string;
@@ -16,14 +16,14 @@ const priorityStyles = {
critical: 'bg-gradient-to-br from-primary-500/10 to-primary-600/5 border-primary-500/20',
infrastructure: 'bg-gradient-to-br from-slate-500/8 to-slate-600/4 border-slate-500/15',
development: 'bg-gradient-to-br from-amber-500/8 to-amber-600/4 border-amber-500/15',
tools: 'bg-black/30 border-stone-600/30'
tools: 'bg-black/30 border-stone-600/30',
} as const;
const priorityIconColors = {
critical: 'text-primary-400',
infrastructure: 'text-slate-400',
development: 'text-amber-400',
tools: 'text-stone-400'
tools: 'text-stone-400',
} as const;
const SectionCard: React.FC<SectionCardProps> = ({
@@ -34,7 +34,7 @@ const SectionCard: React.FC<SectionCardProps> = ({
collapsible = false,
defaultExpanded = true,
hasChanges = false,
loading = false
loading = false,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
@@ -45,11 +45,11 @@ const SectionCard: React.FC<SectionCardProps> = ({
};
return (
<div className={`rounded-xl border backdrop-blur-sm transition-all duration-200 ${priorityStyles[priority]}`}>
<div
className={`rounded-xl border backdrop-blur-sm transition-all duration-200 ${priorityStyles[priority]}`}>
<div
className={`flex items-center justify-between p-6 ${collapsible ? 'cursor-pointer hover:bg-white/5' : ''}`}
onClick={handleToggle}
>
onClick={handleToggle}>
<div className="flex items-center gap-3">
<div className={`flex-shrink-0 ${priorityIconColors[priority]} ${loading ? 'relative' : ''}`}>
{loading ? (
@@ -60,9 +60,7 @@ const SectionCard: React.FC<SectionCardProps> = ({
</div>
<div className="flex items-center gap-2">
<h3 className="text-xl font-semibold text-white font-display">{title}</h3>
{hasChanges && (
<div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
{hasChanges && <div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
{loading && (
<span className="text-sm text-gray-400 ml-2">Loading...</span>
)}
@@ -81,13 +79,11 @@ const SectionCard: React.FC<SectionCardProps> = ({
{(!collapsible || isExpanded) && (
<div className="px-6 pb-6">
<div className="space-y-8">
{children}
</div>
<div className="space-y-8">{children}</div>
</div>
)}
</div>
);
};
export default SectionCard;
export default SectionCard;
+1
View File
@@ -92,6 +92,7 @@ export function SkillActionButton({
version: '0.0.0',
description: skill.description,
runtime: 'quickjs',
setup: skill.hasSetup ? { required: true } : undefined,
});
if (skill.hasSetup) {
onOpenModal();
+41
View File
@@ -0,0 +1,41 @@
/**
* Send Gmail profile (and optionally emails) to the backend via the
* `integration:metadata-sync` socket event so the server can merge them
* into the user's Google OAuth integration metadata.
*/
import { emitViaRustSocket } from '../../../utils/tauriSocket';
const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync';
const PROVIDER_GOOGLE = 'gmail';
/** Gmail profile shape from skill state (snake_case). */
export interface GmailProfileLike {
email_address: string;
messages_total: number;
threads_total: number;
history_id: string;
}
/**
* Emit `integration:metadata-sync` with Gmail profile and emails so the
* backend can merge them into the user's Google OAuth integration.
* No-op when profile is missing or not in Tauri.
*/
export function syncGmailMetadataToBackend(gmailState: GmailProfileLike | undefined): void {
if (!gmailState) return;
const metadata: Record<string, unknown> = {
email_address: gmailState.email_address,
messages_total: gmailState.messages_total,
threads_total: gmailState.threads_total,
history_id: gmailState.history_id,
};
// if (Array.isArray(gmailState.emails) && gmailState.emails.length > 0) {
// metadata.emails = gmailState.emails as GmailEmailSummaryLike[];
// }
const payload = { requestId: crypto.randomUUID(), provider: PROVIDER_GOOGLE, metadata };
void emitViaRustSocket(INTEGRATION_METADATA_SYNC_EVENT, payload);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Send Notion user metadata to the backend via the
* `integration:metadata-sync` socket event so the server can merge it
* into the user's Notion OAuth integration metadata.
*/
import { emitViaRustSocket } from '../../../utils/tauriSocket';
const INTEGRATION_METADATA_SYNC_EVENT = 'integration:metadata-sync';
const PROVIDER_NOTION = 'notion';
export interface NotionUserProfileLike {
id: string;
name?: string | null;
email?: string | null;
type?: string | null;
avatar_url?: string | null;
}
/**
* Emit `integration:metadata-sync` with Notion user profile so the
* backend can merge it into the user's Notion OAuth integration.
* No-op when profile is missing or invalid.
*/
export function syncNotionMetadataToBackend(
profile: NotionUserProfileLike | null | undefined
): void {
if (!profile || !profile.id) return;
const metadata: Record<string, unknown> = {
id: profile.id,
name: profile.name ?? null,
email: profile.email ?? null,
type: profile.type ?? null,
avatar_url: profile.avatar_url ?? null,
};
const payload = {
requestId: crypto.randomUUID(),
provider: PROVIDER_NOTION,
metadata,
};
void emitViaRustSocket(INTEGRATION_METADATA_SYNC_EVENT, payload);
}
+19 -17
View File
@@ -43,42 +43,44 @@ export function deriveConnectionStatus(
// Process is running or ready — use the skill's self-reported state
const hostState = skillState as SkillHostConnectionState | undefined;
if (!hostState) {
// No state pushed yet. Skills that don't maintain an external connection
// (e.g. cron-based skills) may never push host state. If setup is complete
// and the lifecycle says "ready", treat it as connected.
if (setupComplete && lifecycleStatus === "ready") {
const connStatus = hostState?.connection_status;
const authStatus = hostState?.auth_status;
// If the skill hasn't pushed any state, or pushed state without standard
// connection_status / auth_status fields, fall back to lifecycle + setupComplete.
if (!connStatus && !authStatus) {
if (setupComplete && (lifecycleStatus === "ready" || lifecycleStatus === "running")) {
return "connected";
}
if (!hostState) {
return "connecting";
}
// Skill pushed custom state but no connection fields — treat as connecting
return "connecting";
}
const connStatus = hostState.connection_status;
const authStatus = hostState.auth_status;
// Check for errors first
if (connStatus === "error" || authStatus === "error") {
return "error";
}
// Fully connected and authenticated
if (connStatus === "connected" && authStatus === "authenticated") {
return "connected";
}
// Connecting or authenticating
if (connStatus === "connecting" || authStatus === "authenticating") {
return "connecting";
}
// Connected but not authenticated
if (connStatus === "connected" && authStatus === "not_authenticated") {
return "not_authenticated";
// Connected — check auth if the skill uses it
if (connStatus === "connected") {
if (!authStatus || authStatus === "authenticated") {
return "connected";
}
if (authStatus === "not_authenticated") {
return "not_authenticated";
}
}
// Disconnected from service
if (connStatus === "disconnected") {
// If setup is complete but we're disconnected, it might be a reconnecting state
if (setupComplete) {
return "disconnected";
}
+42 -5
View File
@@ -18,6 +18,7 @@ import type {
SkillOptionDefinition,
} from "./types";
import { store } from "../../store";
import { setPrimaryWalletAddressForUser } from "../../store/authSlice";
import {
addSkill,
setSkillStatus,
@@ -34,13 +35,21 @@ class SkillManager {
private runtimes = new Map<string, SkillRuntime>();
/**
* Get skill-specific load parameters (e.g., session data for Telegram)
* Get skill-specific load parameters (e.g., wallet address for wallet skill)
*/
private getSkillLoadParams(_skillId: string): Record<string, unknown> {
private getSkillLoadParams(skillId: string): Record<string, unknown> {
const params: Record<string, unknown> = {};
// For now, just return empty params - skill-specific session data
// will be handled through the skill's own setup process
if (skillId === "wallet") {
const state = store.getState();
const userId = state.user.user?._id;
const primaryAddress =
userId && state.auth.primaryWalletAddressByUser?.[userId];
if (primaryAddress) {
params.walletAddress = primaryAddress;
}
}
return params;
}
@@ -72,7 +81,14 @@ class SkillManager {
// Dead runtime — clean up
this.runtimes.delete(skillId);
}
// Ensure the skill is registered in Redux before dispatching status updates.
// Self-evolved skills are started directly by the Rust engine and never go
// through registerSkill(), so state.skills[skillId] is undefined. Every
// setSkillStatus / setSkillSetupComplete / setSkillTools reducer silently
// no-ops when the key is missing, making the Enable button appear broken.
if (!store.getState().skills.skills[skillId]) {
store.dispatch(addSkill({ manifest }));
}
store.dispatch(setSkillStatus({ skillId, status: "starting" }));
const runtime = new SkillRuntime(manifest);
@@ -267,11 +283,13 @@ class SkillManager {
/**
* Notify a skill that OAuth completed successfully.
* Called by the deep link handler after backend OAuth callback.
* For Gmail, pass extraCredential.accessToken so the skill uses the token directly.
*/
async notifyOAuthComplete(
skillId: string,
integrationId: string,
provider?: string,
extraCredential?: { accessToken?: string },
): Promise<void> {
const runtime = this.runtimes.get(skillId);
if (!runtime || !runtime.isRunning) {
@@ -285,6 +303,7 @@ class SkillManager {
credentialId: integrationId,
provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown",
grantedScopes: manifest?.setup?.oauth?.scopes ?? [],
...extraCredential,
};
await runtime.oauthComplete(credential);
@@ -404,6 +423,24 @@ class SkillManager {
}
}
/**
* Set the wallet address in the frontend app and notify the wallet skill (onLoad).
* Updates Redux (primaryWalletAddressByUser) and, if the wallet skill is running,
* sends load params so the skill receives onLoad({ walletAddress }).
*/
async setWalletAddress(address: string): Promise<void> {
const state = store.getState();
const userId = state.user.user?._id;
if (!userId) {
return;
}
store.dispatch(setPrimaryWalletAddressForUser({ userId, address }));
const runtime = this.runtimes.get("wallet");
if (runtime?.isRunning) {
await runtime.load({ walletAddress: address });
}
}
// -----------------------------------------------------------------------
// Reverse RPC handling
// -----------------------------------------------------------------------
+7 -5
View File
@@ -211,11 +211,13 @@ const Conversations = () => {
const handleNewThread = () => {
const threadId = crypto.randomUUID();
dispatch(createThreadLocal({
id: threadId,
title: 'New Conversation',
createdAt: new Date().toISOString(),
}));
dispatch(
createThreadLocal({
id: threadId,
title: 'New Conversation',
createdAt: new Date().toISOString(),
})
);
navigate(`/conversations/${threadId}`, { replace: true });
};
+367
View File
@@ -0,0 +1,367 @@
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import LottieAnimation from '../components/LottieAnimation';
import { skillManager } from '../lib/skills/manager';
import { setEncryptionKeyForUser } from '../store/authSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import {
deriveAesKeyFromMnemonic,
deriveEvmAddressFromMnemonic,
generateMnemonicPhrase,
validateMnemonicPhrase,
} from '../utils/cryptoKeys';
const WORD_COUNT = 24;
const Mnemonic = () => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const user = useAppSelector(state => state.user.user);
const [mode, setMode] = useState<'generate' | 'import'>('generate');
const [copied, setCopied] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Generate mode state
const mnemonic = useMemo(() => generateMnemonicPhrase(), []);
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
// Import mode state
const [importWords, setImportWords] = useState<string[]>(Array(WORD_COUNT).fill(''));
const [importValid, setImportValid] = useState<boolean | null>(null);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), 3000);
return () => clearTimeout(timer);
}
}, [copied]);
// Reset state when switching modes
useEffect(() => {
setConfirmed(false);
setError(null);
setImportValid(null);
}, [mode]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(mnemonic);
setCopied(true);
} catch {
const textarea = document.createElement('textarea');
textarea.value = mnemonic;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
}
}, [mnemonic]);
const handleImportWordChange = useCallback(
(index: number, value: string) => {
// Handle paste of full mnemonic phrase
const pastedWords = value.trim().split(/\s+/);
if (pastedWords.length > 1) {
const newWords = [...importWords];
for (let i = 0; i < Math.min(pastedWords.length, WORD_COUNT - index); i++) {
newWords[index + i] = pastedWords[i].toLowerCase();
}
setImportWords(newWords);
setImportValid(null);
// Focus the next empty field or the last filled field
const nextEmpty = newWords.findIndex(w => !w);
const focusIndex = nextEmpty === -1 ? WORD_COUNT - 1 : nextEmpty;
inputRefs.current[focusIndex]?.focus();
return;
}
const newWords = [...importWords];
newWords[index] = value.toLowerCase().trim();
setImportWords(newWords);
setImportValid(null);
// Auto-advance to next input when a word is entered
if (value.trim() && index < WORD_COUNT - 1) {
inputRefs.current[index + 1]?.focus();
}
},
[importWords]
);
const handleImportKeyDown = useCallback(
(index: number, e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !importWords[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
}
},
[importWords]
);
const handleValidateImport = useCallback(() => {
const phrase = importWords.join(' ').trim();
const filledWords = importWords.filter(w => w.trim());
if (filledWords.length !== WORD_COUNT) {
setError(`Please enter all ${WORD_COUNT} words.`);
setImportValid(false);
return false;
}
const isValid = validateMnemonicPhrase(phrase);
setImportValid(isValid);
if (!isValid) {
setError('Invalid recovery phrase. Please check your words and try again.');
return false;
}
setError(null);
return true;
}, [importWords]);
const handleContinue = async () => {
setError(null);
setLoading(true);
try {
let phraseToUse: string;
if (mode === 'import') {
if (!handleValidateImport()) {
setLoading(false);
return;
}
phraseToUse = importWords.join(' ').trim();
} else {
if (!confirmed) {
setLoading(false);
return;
}
phraseToUse = mnemonic;
}
const aesKey = deriveAesKeyFromMnemonic(phraseToUse);
const walletAddress = deriveEvmAddressFromMnemonic(phraseToUse);
if (!user?._id) {
const msg = 'User not loaded. Please sign in again or refresh the page.';
setError(msg);
console.error('[Mnemonic] Cannot save encryption key: user not loaded');
return;
}
dispatch(setEncryptionKeyForUser({ userId: user._id, key: aesKey }));
await skillManager.setWalletAddress(walletAddress);
navigate('/home');
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.');
} finally {
setLoading(false);
}
};
const isImportComplete = importWords.every(w => w.trim());
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
return (
<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="/lottie/safe3.json" height={120} width={120} />
</div>
<div className="glass rounded-3xl p-8 shadow-large animate-fade-up">
{mode === 'generate' ? (
<>
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Your Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Write down these 24 words in order and store them somewhere safe. This phrase is
used to encrypt your data and can never be recovered if lost.
</p>
</div>
{/* Mnemonic Grid */}
<div className="bg-stone-900/5 rounded-2xl p-4 mb-4">
<div className="grid grid-cols-3 gap-2">
{words.map((word, index) => (
<div
key={index}
className="flex items-center gap-2 bg-white/60 rounded-lg px-3 py-2 text-sm">
<span className="text-stone-400 font-mono text-xs w-5 text-right">
{index + 1}.
</span>
<span className="font-mono font-medium text-stone-800">{word}</span>
</div>
))}
</div>
</div>
{/* Copy Button */}
<button
onClick={handleCopy}
className="w-full flex items-center justify-center gap-2 border border-stone-200 hover:bg-stone-50 text-stone-700 font-medium py-2.5 text-sm rounded-xl transition-all duration-200 mb-3">
{copied ? (
<>
<svg
className="w-4 h-4 text-sage-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-sage-600">Copied to Clipboard</span>
</>
) : (
<>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<span>Copy to Clipboard</span>
</>
)}
</button>
{/* Import existing link */}
<button
onClick={() => setMode('import')}
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
I already have a recovery phrase
</button>
{/* Warning */}
<div className="flex items-start gap-3 bg-amber-50 border border-amber-200 rounded-xl p-3 mb-4">
<svg
className="w-5 h-5 text-amber-500 shrink-0 mt-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<p className="text-xs text-amber-800">
Never share your recovery phrase with anyone. Anyone with these words can access
your encrypted data. AlphaHuman will never ask for your recovery phrase.
</p>
</div>
{/* Confirmation Checkbox */}
<label className="flex items-start gap-3 cursor-pointer mb-4">
<input
type="checkbox"
checked={confirmed}
onChange={e => setConfirmed(e.target.checked)}
className="mt-0.5 w-4 h-4 rounded border-stone-300 text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-stone-600">
I have saved my recovery phrase in a safe place
</span>
</label>
</>
) : (
<>
<div className="text-center mb-4">
<h1 className="text-xl font-bold mb-2">Import Recovery Phrase</h1>
<p className="opacity-70 text-sm">
Enter your existing 24-word recovery phrase below. You can also paste the full
phrase into the first field.
</p>
</div>
{/* Import Word Inputs Grid */}
<div className="bg-stone-900/5 rounded-2xl p-4 mb-4">
<div className="grid grid-cols-3 gap-2">
{importWords.map((word, index) => (
<div key={index} className="flex items-center gap-1.5">
<span className="text-stone-400 font-mono text-xs w-5 text-right shrink-0">
{index + 1}.
</span>
<input
ref={el => {
inputRefs.current[index] = el;
}}
type="text"
value={word}
onChange={e => handleImportWordChange(index, e.target.value)}
onKeyDown={e => handleImportKeyDown(index, e)}
autoComplete="off"
spellCheck={false}
className={`w-full font-mono text-sm font-medium px-2 py-1.5 rounded-lg border bg-white/60 outline-none transition-colors ${
importValid === false && word.trim()
? 'border-coral-300 focus:border-coral-400'
: importValid === true
? 'border-sage-300 focus:border-sage-400'
: 'border-stone-200 focus:border-primary-400'
}`}
/>
</div>
))}
</div>
</div>
{/* Validation status */}
{importValid === true && (
<div className="flex items-center gap-2 text-sage-600 text-sm mb-3 justify-center">
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span>Valid recovery phrase</span>
</div>
)}
{/* Back to generate link */}
<button
onClick={() => setMode('generate')}
className="w-full text-center text-sm text-primary-500 hover:text-primary-600 transition-colors mb-3">
Generate a new recovery phrase instead
</button>
</>
)}
{error && <p className="text-coral-500 text-sm mb-3 text-center">{error}</p>}
{/* Continue Button */}
<button
onClick={handleContinue}
disabled={!canContinue || loading}
className="w-full flex items-center justify-center space-x-3 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold py-2.5 text-sm rounded-xl transition-all duration-300 hover:shadow-medium">
<span>
{loading
? 'Securing Your Data...'
: mode === 'import'
? 'Import & Continue'
: "I'm Ready! Let's Go!"}
</span>
</button>
</div>
</div>
</div>
);
};
export default Mnemonic;
+1 -1
View File
@@ -48,7 +48,7 @@ const Onboarding = () => {
if (user?._id) {
dispatch(setOnboardedForUser({ userId: user._id, value: true }));
}
navigate('/home');
navigate('/mnemonic');
};
const renderStep = () => {
+110 -7
View File
@@ -8,9 +8,16 @@ import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { type ReactNode, useEffect, useRef } from 'react';
import {
type GmailProfileLike,
syncGmailMetadataToBackend,
} from '../lib/gmail/services/metadataSync';
import { syncNotionMetadataToBackend } from '../lib/notion/services/metadataSync';
import { skillManager } from '../lib/skills/manager';
import type { SkillManifest } from '../lib/skills/types';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
import { type GmailProfile, setGmailProfile } from '../store/gmailSlice';
import { setNotionProfile, type NotionUserProfile } from '../store/notionSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice';
import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config';
@@ -53,19 +60,102 @@ async function discoverSkills(): Promise<SkillManifest[]> {
// Provider
// ---------------------------------------------------------------------------
/** Normalize event payload: Rust emits { skillId, state }; ensure we get both. */
function parseSkillStatePayload(
payload: unknown
): { skillId: string; state: Record<string, unknown> } | null {
if (payload == null || typeof payload !== 'object') return null;
const raw = payload as Record<string, unknown>;
const skillId = raw.skillId as string | undefined;
const state = (raw.state ?? raw) as Record<string, unknown> | undefined;
if (!skillId || state == null || typeof state !== 'object') return null;
return { skillId, state };
}
/** Sync profile and emails from gmail skill state into gmailSlice and send to backend via socket. */
function syncGmailStateToSlice(
gmailState: Record<string, unknown> | undefined,
dispatch: ReturnType<typeof useAppDispatch>
): void {
if (!gmailState || typeof gmailState !== 'object') return;
dispatch(
setGmailProfile(
gmailState.profile !== undefined && gmailState.profile != null
? (gmailState.profile as GmailProfile)
: null
)
);
syncGmailMetadataToBackend(gmailState.profile as GmailProfileLike);
}
async function syncNotionUserOnConnect(
dispatch: ReturnType<typeof useAppDispatch>
): Promise<void> {
try {
const toolResult = await skillManager.callTool('notion', 'get-user', { user_id: 'me' });
if (!toolResult || toolResult.isError || toolResult.content.length === 0) {
return;
}
const first = toolResult.content[0];
const raw = first?.text;
if (!raw) return;
const parsed = JSON.parse(raw) as NotionUserProfile | { error?: string };
if ('error' in parsed && parsed.error) {
return;
}
const profile = parsed as NotionUserProfile;
dispatch(setNotionProfile(profile));
syncNotionMetadataToBackend(profile);
} catch (e) {
console.error('[SkillProvider] Failed to call Notion get-user tool after connect:', e);
}
}
export default function SkillProvider({ children }: { children: ReactNode }) {
const { token } = useAppSelector(state => state.auth);
const skillsState = useAppSelector(state => state.skills.skills);
const skillStates = useAppSelector(state => state.skills.skillStates);
const dispatch = useAppDispatch();
const initRef = useRef(false);
const lastNotionConnectionStatusRef = useRef<string | undefined>(undefined);
// Keep gmailSlice in sync with skills.skillStates.gmail (event handler + rehydration)
const gmailSkillState = skillStates?.gmail as Record<string, unknown> | undefined;
useEffect(() => {
if (!gmailSkillState) return;
syncGmailStateToSlice(gmailSkillState, dispatch);
}, [gmailSkillState, dispatch]);
// When Notion connection_status transitions to "connected", fetch the current user
// via the notion get-user tool, store it in notionSlice, and sync metadata to backend.
const notionSkillState = skillStates?.notion as Record<string, unknown> | undefined;
useEffect(() => {
if (!notionSkillState || typeof notionSkillState !== 'object') return;
const connectionStatus = notionSkillState.connection_status as string | undefined;
const prev = lastNotionConnectionStatusRef.current;
lastNotionConnectionStatusRef.current = connectionStatus;
if (connectionStatus === 'connected' && prev !== 'connected') {
void syncNotionUserOnConnect(dispatch);
}
}, [notionSkillState, dispatch]);
// Listen for skill state changes emitted from the Rust runtime event loop
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<{ skillId: string; state: Record<string, unknown> }>('skill-state-changed', event => {
const { skillId, state: newState } = event.payload;
const parsed = parseSkillStatePayload(event.payload);
if (!parsed) return;
const { skillId, state: newState } = parsed;
dispatch(setSkillState({ skillId, state: newState }));
// Transfer Gmail skill state to gmail store (also synced by effect from skillStates.gmail)
if (skillId === 'gmail') {
syncGmailStateToSlice(newState, dispatch);
}
})
.then(fn => {
unlisten = fn;
@@ -79,6 +169,25 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
};
}, [dispatch]);
// Fallback: when gmail skill is ready, fetch state from backend (covers events missed before listener attached)
const gmailStatus = skillsState?.gmail?.status;
useEffect(() => {
if (gmailStatus !== 'ready') return;
const timeoutId = window.setTimeout(() => {
invoke<{ state?: Record<string, unknown> } | null>('runtime_get_skill_state', {
skillId: 'gmail',
})
.then(snapshot => {
if (snapshot?.state && typeof snapshot.state === 'object') {
dispatch(setSkillState({ skillId: 'gmail', state: snapshot.state }));
syncGmailStateToSlice(snapshot.state, dispatch);
}
})
.catch(() => {});
}, 800);
return () => window.clearTimeout(timeoutId);
}, [gmailStatus, dispatch]);
// Listen for skill runtime errors and surface them in the error notification
useEffect(() => {
let unlisten: (() => void) | undefined;
@@ -134,17 +243,11 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
if (DEV_AUTO_LOAD_SKILL) {
const autoLoadManifest = manifests.find(m => m.id === DEV_AUTO_LOAD_SKILL);
if (autoLoadManifest) {
console.log(`[SkillProvider] Auto-loading skill from env: ${DEV_AUTO_LOAD_SKILL}`);
try {
await skillManager.startSkill(autoLoadManifest);
console.log(`[SkillProvider] Successfully auto-loaded skill: ${DEV_AUTO_LOAD_SKILL}`);
} catch (err) {
console.error(`[SkillProvider] Failed to auto-load skill ${DEV_AUTO_LOAD_SKILL}:`, err);
}
} else {
console.warn(
`[SkillProvider] DEV_AUTO_LOAD_SKILL="${DEV_AUTO_LOAD_SKILL}" specified but skill not found in discovered skills`
);
}
}
+19
View File
@@ -5,6 +5,11 @@ interface ConsumeLoginTokenResponse {
data: { jwtToken: string };
}
interface IntegrationTokensResponse {
success: boolean;
data?: { encrypted: string };
}
/**
* Consume a verified login token and return the JWT.
* Works for both Telegram and OAuth login tokens.
@@ -22,3 +27,17 @@ export async function consumeLoginToken(loginToken: string): Promise<string> {
}
return response.data.jwtToken;
}
/**
* Fetch encrypted OAuth tokens for an integration using a client-provided key.
* POST /auth/integrations/:integrationId/tokens (auth required)
*/
export async function fetchIntegrationTokens(
integrationId: string,
key: string
): Promise<IntegrationTokensResponse> {
return apiClient.post<IntegrationTokensResponse>(
`/auth/integrations/${encodeURIComponent(integrationId)}/tokens`,
{ key }
);
}
+1 -4
View File
@@ -22,9 +22,6 @@ export const userApi = {
* POST /settings/onboarding-complete
*/
onboardingComplete: async (): Promise<void> => {
await apiClient.post<{ success: boolean; data: unknown }>(
'/settings/onboarding-complete',
{}
);
await apiClient.post<{ success: boolean; data: unknown }>('/settings/onboarding-complete', {});
},
};
+6
View File
@@ -5,3 +5,9 @@ export const selectIsOnboarded = (state: RootState): boolean => {
if (!userId) return false;
return state.auth.isOnboardedByUser[userId] ?? false;
};
export const selectHasEncryptionKey = (state: RootState): boolean => {
const userId = state.user.user?._id;
if (!userId) return false;
return !!state.auth.encryptionKeyByUser[userId];
};
+26 -1
View File
@@ -9,12 +9,18 @@ export interface AuthState {
isOnboardedByUser: Record<string, boolean>;
/** Analytics consent per user id (opt-in during onboarding) */
isAnalyticsEnabledByUser: Record<string, boolean>;
/** AES encryption key (hex) derived from mnemonic, per user id */
encryptionKeyByUser: Record<string, string>;
/** Primary EVM wallet address (0x...) derived from mnemonic, per user id */
primaryWalletAddressByUser: Record<string, string>;
}
const initialState: AuthState = {
token: null,
isOnboardedByUser: {},
isAnalyticsEnabledByUser: {},
encryptionKeyByUser: {},
primaryWalletAddressByUser: {},
};
const authSlice = createSlice({
@@ -26,6 +32,8 @@ const authSlice = createSlice({
},
_clearToken: state => {
state.token = null;
state.encryptionKeyByUser = {};
state.primaryWalletAddressByUser = {};
},
setOnboardedForUser: (state, action: PayloadAction<{ userId: string; value: boolean }>) => {
const { userId, value } = action.payload;
@@ -35,6 +43,17 @@ const authSlice = createSlice({
const { userId, enabled } = action.payload;
state.isAnalyticsEnabledByUser[userId] = enabled;
},
setEncryptionKeyForUser: (state, action: PayloadAction<{ userId: string; key: string }>) => {
const { userId, key } = action.payload;
state.encryptionKeyByUser[userId] = key;
},
setPrimaryWalletAddressForUser: (
state,
action: PayloadAction<{ userId: string; address: string }>
) => {
const { userId, address } = action.payload;
state.primaryWalletAddressByUser[userId] = address;
},
},
});
@@ -45,5 +64,11 @@ export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispat
dispatch(clearTeamState());
});
export const { setToken, setOnboardedForUser, setAnalyticsForUser } = authSlice.actions;
export const {
setToken,
setOnboardedForUser,
setAnalyticsForUser,
setEncryptionKeyForUser,
setPrimaryWalletAddressForUser,
} = authSlice.actions;
export default authSlice.reducer;
+49
View File
@@ -0,0 +1,49 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export interface GmailEmailSummary {
id: string;
threadId: string;
snippet?: string;
subject?: string;
from?: string;
date?: string;
}
export interface GmailProfile {
email_address: string;
messages_total: number;
threads_total: number;
history_id: string;
}
interface GmailState {
/** Emails fetched after OAuth connection (from Gmail skill) */
emails: GmailEmailSummary[];
/** Profile of the connected Gmail user (from Gmail skill) */
profile: GmailProfile | null;
}
const initialState: GmailState = { emails: [], profile: null };
const gmailSlice = createSlice({
name: 'gmail',
initialState,
reducers: {
setGmailEmails(state, action: PayloadAction<GmailEmailSummary[]>) {
state.emails = action.payload;
},
clearGmailEmails(state) {
state.emails = [];
},
setGmailProfile(state, action: PayloadAction<GmailProfile | null>) {
state.profile = action.payload;
},
clearGmailProfile(state) {
state.profile = null;
},
},
});
export const { setGmailEmails, clearGmailEmails, setGmailProfile, clearGmailProfile } =
gmailSlice.actions;
export default gmailSlice.reducer;
+12 -2
View File
@@ -18,7 +18,9 @@ import { storeSession } from '../utils/tauriCommands';
import aiReducer from './aiSlice';
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
import daemonReducer from './daemonSlice';
import gmailReducer from './gmailSlice';
import inviteReducer from './inviteSlice';
import notionReducer from './notionSlice';
import skillsReducer from './skillsSlice';
import socketReducer from './socketSlice';
import teamReducer from './teamSlice';
@@ -29,7 +31,13 @@ import userReducer from './userSlice';
const authPersistConfig = {
key: 'auth',
storage,
whitelist: ['token', 'isOnboardedByUser', 'isAnalyticsEnabledByUser'],
whitelist: [
'token',
'isOnboardedByUser',
'isAnalyticsEnabledByUser',
'encryptionKeyByUser',
'primaryWalletAddressByUser',
],
};
// Persist config for AI state (config only)
@@ -42,7 +50,7 @@ const skillsPersistConfig = { key: 'skills', storage, whitelist: ['skills'] };
const threadPersistConfig = {
key: 'thread',
storage,
whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId']
whitelist: ['panelWidth', 'lastViewedAt', 'threads', 'messagesByThreadId', 'selectedThreadId'],
};
const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
@@ -89,9 +97,11 @@ export const store = configureStore({
daemon: daemonReducer,
ai: persistedAiReducer,
skills: persistedSkillsReducer,
gmail: gmailReducer,
team: teamReducer,
thread: persistedThreadReducer,
invite: inviteReducer,
notion: notionReducer,
},
middleware: getDefaultMiddleware => {
const middleware = getDefaultMiddleware({
+33
View File
@@ -0,0 +1,33 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export interface NotionUserProfile {
id: string;
name?: string | null;
email?: string | null;
type?: string | null;
avatar_url?: string | null;
}
interface NotionState {
/** Profile of the connected Notion user (from Notion skill) */
profile: NotionUserProfile | null;
}
const initialState: NotionState = { profile: null };
const notionSlice = createSlice({
name: 'notion',
initialState,
reducers: {
setNotionProfile(state, action: PayloadAction<NotionUserProfile | null>) {
state.profile = action.payload;
},
clearNotionProfile(state) {
state.profile = null;
},
},
});
export const { setNotionProfile, clearNotionProfile } = notionSlice.actions;
export default notionSlice.reducer;
+8 -1
View File
@@ -79,7 +79,14 @@ const skillsSlice = createSlice({
state,
action: PayloadAction<{ skillId: string; state: Record<string, unknown> }>
) {
state.skillStates[action.payload.skillId] = action.payload.state;
const { skillId, state: incomingState } = action.payload;
const existing = state.skillStates[skillId] as Record<string, unknown> | undefined;
// Preserve frontend-only keys (e.g. oauthTokens from deep link) that the skill never sends
const preserved =
existing && typeof existing.oauthTokens === 'object' && existing.oauthTokens !== null
? { oauthTokens: existing.oauthTokens }
: {};
state.skillStates[skillId] = { ...incomingState, ...preserved };
},
removeSkill(state, action: PayloadAction<string>) {
+14 -9
View File
@@ -17,8 +17,8 @@ interface ThreadState {
messages: ThreadMessage[];
// Keep these API states (NOT persisted)
isLoadingMessages: boolean; // For AI response waiting
messagesError: string | null; // For send API errors
isLoadingMessages: boolean; // For AI response waiting
messagesError: string | null; // For send API errors
sendStatus: 'idle' | 'loading' | 'success' | 'error';
sendError: string | null;
deleteStatus: 'idle' | 'loading' | 'success' | 'error';
@@ -194,9 +194,7 @@ const threadSlice = createSlice({
// CRITICAL FIX: Ensure the preceding user message is also persisted
// Find the last user message that might not be in persistent storage yet
const lastUserMessage = state.messages
.filter(m => m.sender === 'user')
.pop();
const lastUserMessage = state.messages.filter(m => m.sender === 'user').pop();
if (lastUserMessage) {
const persistedMessages = state.messagesByThreadId[state.selectedThreadId];
@@ -233,7 +231,10 @@ const threadSlice = createSlice({
state.lastViewedAt[action.payload] = ts;
},
// Local thread management
createThreadLocal: (state, action: { payload: { id: string; title: string; createdAt: string } }) => {
createThreadLocal: (
state,
action: { payload: { id: string; title: string; createdAt: string } }
) => {
const newThread: Thread = {
id: action.payload.id,
title: action.payload.title,
@@ -269,7 +270,10 @@ const threadSlice = createSlice({
state.selectedThreadId = null;
}
},
updateMessagesForThread: (state, action: { payload: { threadId: string; messages: ThreadMessage[] } }) => {
updateMessagesForThread: (
state,
action: { payload: { threadId: string; messages: ThreadMessage[] } }
) => {
const { threadId, messages } = action.payload;
state.messagesByThreadId[threadId] = messages;
@@ -277,10 +281,11 @@ const threadSlice = createSlice({
const thread = state.threads.find(t => t.id === threadId);
if (thread) {
thread.messageCount = messages.length;
thread.lastMessageAt = messages.length > 0 ? messages[messages.length - 1].createdAt : thread.createdAt;
thread.lastMessageAt =
messages.length > 0 ? messages[messages.length - 1].createdAt : thread.createdAt;
}
},
clearAllThreads: (state) => {
clearAllThreads: state => {
state.threads = [];
state.messagesByThreadId = {};
state.selectedThreadId = null;
+70
View File
@@ -0,0 +1,70 @@
import { pbkdf2 } from '@noble/hashes/pbkdf2.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { keccak_256 } from '@noble/hashes/sha3.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { getPublicKey } from '@noble/secp256k1';
import { HDKey } from '@scure/bip32';
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
/**
* Generate a 24-word BIP39 mnemonic phrase (256-bit entropy).
*/
export function generateMnemonicPhrase(): string {
return generateMnemonic(wordlist, 256);
}
/**
* Validate a BIP39 mnemonic phrase.
*/
export function validateMnemonicPhrase(mnemonic: string): boolean {
return validateMnemonic(mnemonic, wordlist);
}
/**
* Derive a 256-bit AES encryption key from a mnemonic phrase.
* Uses BIP39 seed derivation followed by PBKDF2-SHA256.
* Returns the key as a hex string.
*/
export function deriveAesKeyFromMnemonic(mnemonic: string): string {
// Get the BIP39 seed (512-bit) from the mnemonic
const seed = mnemonicToSeedSync(mnemonic);
// Derive a 256-bit AES key using PBKDF2 with the seed
const salt = new TextEncoder().encode('alphahuman-aes-key-v1');
const derivedKey = pbkdf2(sha256, seed, salt, { c: 100000, dkLen: 32 });
return bytesToHex(derivedKey);
}
/** BIP44 path for first Ethereum account: m/44'/60'/0'/0/0 */
const EVM_DERIVATION_PATH = "m/44'/60'/0'/0/0";
/**
* Derive the first EVM wallet address (Ethereum BIP44) from a mnemonic phrase.
* Uses path m/44'/60'/0'/0/0. Returns a checksummed 0x-prefixed address.
*/
export function deriveEvmAddressFromMnemonic(mnemonic: string): string {
const seed = mnemonicToSeedSync(mnemonic);
const hdkey = HDKey.fromMasterSeed(seed);
const derived = hdkey.derive(EVM_DERIVATION_PATH);
const privateKey = derived.privateKey;
if (!privateKey) throw new Error('Failed to derive private key');
// Ethereum address = keccak256(uncompressed public key without 0x04)[12:]
const pubKey = getPublicKey(privateKey, false); // uncompressed, 65 bytes
const hash = keccak_256(pubKey.slice(1));
const addressBytes = hash.slice(-20);
const hex = bytesToHex(addressBytes);
return toChecksumAddress('0x' + hex);
}
/** Simple checksum: lowercase with 0x, then capitalize by hash. */
function toChecksumAddress(address: string): string {
const a = address.replace(/^0x/i, '').toLowerCase();
const hash = bytesToHex(keccak_256(new TextEncoder().encode(a)));
let result = '0x';
for (let i = 0; i < 40; i++) {
result += parseInt(hash[i], 16) >= 8 ? a[i].toUpperCase() : a[i];
}
return result;
}
+146 -9
View File
@@ -1,10 +1,39 @@
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 { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
import { store } from '../store';
import { setToken } from '../store/authSlice';
import { setSkillState } from '../store/skillsSlice';
import { skillManager } from '../lib/skills/manager';
import {
decryptIntegrationTokens,
hexToBase64,
type IntegrationTokensPayload,
} from './integrationTokensCrypto';
function getCurrentUserId(): string | null {
const state = store.getState();
const explicitId = state.user.user?._id;
if (explicitId) return explicitId;
const token = state.auth.token;
if (!token) return null;
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const padLen = (4 - (payloadBase64.length % 4)) % 4;
const padded = padLen ? payloadBase64 + '='.repeat(padLen) : payloadBase64;
const payloadJson = atob(padded);
const payload = JSON.parse(payloadJson);
return payload.tgUserId || payload.userId || payload.sub || null;
} catch {
return null;
}
}
/**
* Handle an `alphahuman://auth?token=...` deep link for login.
@@ -54,9 +83,7 @@ const handlePaymentDeepLink = async (parsed: URL) => {
console.log('[DeepLink] Payment success, session_id:', sessionId);
// Broadcast to the app so billing components can react
window.dispatchEvent(
new CustomEvent('payment:success', { detail: { sessionId } }),
);
window.dispatchEvent(new CustomEvent('payment:success', { detail: { sessionId } }));
// Navigate to billing settings to show confirmation
window.location.hash = '/settings/billing';
@@ -95,7 +122,118 @@ const handleOAuthDeepLink = async (parsed: URL) => {
console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`);
try {
await skillManager.notifyOAuthComplete(skillId, integrationId);
const state = store.getState();
const userId = getCurrentUserId();
if (!userId) {
console.warn('[DeepLink] Cannot fetch integration tokens: no current user id');
return;
}
const encryptionKeyHex = state.auth.encryptionKeyByUser[userId];
if (!encryptionKeyHex || typeof encryptionKeyHex !== 'string') {
console.warn(
'[DeepLink] Cannot fetch integration tokens: no encryption key found for user',
userId
);
return;
}
const trimmedHex = encryptionKeyHex.trim().replace(/^0x/i, '');
if (!trimmedHex || trimmedHex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(trimmedHex)) {
const msg =
'[DeepLink] Cannot fetch integration tokens: encryption key must be non-empty hex (even length, [0-9a-fA-F])';
console.error(msg, { userId, encryptionKeyHex });
enqueueError({
id: crypto.randomUUID(),
timestamp: Date.now(),
source: 'manual',
title: 'Deep link integration tokens: invalid encryption key',
message: msg,
sentryEvent: buildManualSentryEvent(
{ type: 'DeepLinkIntegrationTokensInvalidKey', value: msg },
{ component: 'desktopDeepLinkListener', userId, encryptionKeyHex }
),
});
return;
}
let keyForBackend: string;
try {
keyForBackend = hexToBase64(trimmedHex);
} catch (e) {
const msg = '[DeepLink] Cannot fetch integration tokens: encryption key conversion failed';
console.error(msg, { userId, encryptionKeyHex, error: e });
const err = e instanceof Error ? e : new Error(String(e));
enqueueError({
id: crypto.randomUUID(),
timestamp: Date.now(),
source: 'manual',
title: 'Deep link integration tokens: encryption key conversion failed',
message: err.message,
sentryEvent: buildManualSentryEvent(
{ type: 'DeepLinkIntegrationTokensHexToBase64Error', value: err.message },
{ component: 'desktopDeepLinkListener', userId, encryptionKeyHex }
),
originalError: err,
});
return;
}
if (!keyForBackend) {
const msg =
'[DeepLink] Cannot fetch integration tokens: encryption key produced empty base64';
console.error(msg, { userId, encryptionKeyHex });
enqueueError({
id: crypto.randomUUID(),
timestamp: Date.now(),
source: 'manual',
title: 'Deep link integration tokens: empty key for backend',
message: msg,
sentryEvent: buildManualSentryEvent(
{ type: 'DeepLinkIntegrationTokensEmptyKey', value: msg },
{ component: 'desktopDeepLinkListener', userId, encryptionKeyHex }
),
});
return;
}
const response = await fetchIntegrationTokens(integrationId, keyForBackend);
if (!response.success || !response.data?.encrypted) {
console.warn(
'[DeepLink] Integration tokens response missing encrypted payload for integration',
integrationId
);
return;
}
const existingState = state.skills.skillStates[skillId] ?? {};
store.dispatch(
setSkillState({
skillId,
state: {
...existingState,
oauthTokens: {
...(existingState.oauthTokens as Record<string, { encrypted: string }> | undefined),
[integrationId]: { encrypted: response.data.encrypted },
},
},
})
);
// For OAuth-capable skills (e.g. Gmail, Notion), pass decrypted access token so the
// skill can use it directly when supported instead of always going through the proxy.
let extraCredential: { accessToken?: string } | undefined;
try {
const decryptedJson = await decryptIntegrationTokens(response.data.encrypted, trimmedHex);
const payload = JSON.parse(decryptedJson) as IntegrationTokensPayload;
if (payload.accessToken) {
extraCredential = { accessToken: payload.accessToken };
}
} catch (e) {
console.warn('[DeepLink] Could not decrypt integration token for skill:', e);
}
await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, extraCredential);
} catch (err) {
console.error('[DeepLink] Failed to notify OAuth complete:', err);
}
@@ -173,9 +311,8 @@ export const setupDesktopDeepLinkListener = async () => {
if (typeof window !== 'undefined') {
// window.__simulateDeepLink('alphahuman://auth?token=1234567890')
// window.__simulateDeepLink('alphahuman://oauth/success?integrationId=6989ef9c8e8bf1b6d991a08c&skillId=notion')
(
window as Window & { __simulateDeepLink?: (url: string) => Promise<void> }
).__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]);
const win = window as Window & { __simulateDeepLink?: (url: string) => Promise<void> };
win.__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]);
}
} catch (err) {
console.error('[DeepLink] Setup failed:', err);
+171
View File
@@ -0,0 +1,171 @@
/**
* Helpers for encrypting/decrypting OAuth integration tokens.
* Matches backend format: IV (16 bytes) + AuthTag (16 bytes) + EncryptedData.
* IV is derived from SHA-256(message) for encryption (deterministic).
*/
export type IntegrationTokensPayload = {
accessToken: string;
refreshToken: string;
/** ISO timestamp string */
expiresAt: string;
};
/** Stored value: encrypted blob only; decrypt with key when needed */
export type StoredIntegrationTokens = { encrypted: string };
const HEX_REGEX = /^[0-9a-fA-F]*$/;
export function hexToBytes(hex: string): Uint8Array {
const cleanHex = hex.trim().replace(/^0x/i, '');
if (!cleanHex) return new Uint8Array();
if (cleanHex.length % 2 !== 0) {
throw new TypeError(`hexToBytes: hex string must have even length (got ${cleanHex.length})`);
}
if (!HEX_REGEX.test(cleanHex)) {
throw new TypeError('hexToBytes: hex string must contain only [0-9a-fA-F] characters');
}
const bytes = new Uint8Array(cleanHex.length / 2);
for (let i = 0; i < cleanHex.length; i += 2) {
bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16);
}
return bytes;
}
export function hexToBase64(hex: string): string {
const bytes = hexToBytes(hex);
if (bytes.length === 0) return '';
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
export function base64ToBytes(b64: string): Uint8Array {
let normalized = b64.replace(/-/g, '+').replace(/_/g, '/');
const pad = normalized.length % 4;
if (pad === 2) normalized += '==';
else if (pad === 3) normalized += '=';
const binary = atob(normalized);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Decrypt an encrypted tokens payload (base64) using a 32-byte key (hex).
* Backend format: IV (16) + AuthTag (16) + EncryptedData.
*/
export async function decryptIntegrationTokens(
encryptedPayload: string,
keyHex: string
): Promise<string> {
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new Error('Web Crypto API is not available for decryption');
}
const keyBytes = hexToBytes(keyHex);
if (keyBytes.length !== 32) {
throw new Error('Invalid encryption key: expected 32-byte AES-GCM key');
}
const combined = base64ToBytes(encryptedPayload);
if (combined.length <= 32) {
throw new Error('Encrypted payload too short');
}
const iv = combined.slice(0, 16);
const authTag = combined.slice(16, 32);
const encryptedData = combined.slice(32);
const ciphertextWithTag = new Uint8Array(encryptedData.length + authTag.length);
ciphertextWithTag.set(encryptedData, 0);
ciphertextWithTag.set(authTag, encryptedData.length);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes as unknown as BufferSource,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
cryptoKey,
ciphertextWithTag as unknown as BufferSource
);
return new TextDecoder().decode(decrypted);
}
/**
* Encrypt a plaintext string (e.g. JSON) using a 32-byte key (hex).
* Matches backend: deterministic IV = first 16 bytes of SHA-256(message).
* Returns base64(IV + AuthTag + EncryptedData).
*/
export async function encryptIntegrationTokens(plaintext: string, keyHex: string): Promise<string> {
if (typeof crypto === 'undefined' || !crypto.subtle) {
throw new Error('Web Crypto API is not available for encryption');
}
const keyBytes = hexToBytes(keyHex);
if (keyBytes.length !== 32) {
throw new Error('Invalid encryption key: expected 32-byte AES-GCM key');
}
try {
const payload = JSON.parse(plaintext) as Record<string, unknown>;
if (typeof payload.expiresAt !== 'string' || !payload.expiresAt.trim()) {
throw new Error(
'Payload must include a non-empty expiresAt field when using deterministic IV'
);
}
} catch (e) {
if (e instanceof SyntaxError) {
throw new Error(
'Plaintext must be JSON with a non-empty expiresAt field when using deterministic IV'
);
}
throw e;
}
const messageBytes = new TextEncoder().encode(plaintext);
// Deterministic IV for backend compatibility. TODO: Prefer a random 12-byte IV
// (crypto.getRandomValues) prepended to ciphertext; update decrypt to handle both formats.
const hashBuffer = await crypto.subtle.digest('SHA-256', messageBytes);
const iv = new Uint8Array(hashBuffer).slice(0, 16);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes as unknown as BufferSource,
{ name: 'AES-GCM' },
false,
['encrypt']
);
const encryptedBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, tagLength: 128 },
cryptoKey,
messageBytes
);
const encryptedArray = new Uint8Array(encryptedBuffer);
const authTag = encryptedArray.slice(-16);
const encryptedData = encryptedArray.slice(0, -16);
const combined = new Uint8Array(iv.length + authTag.length + encryptedData.length);
combined.set(iv, 0);
combined.set(authTag, iv.length);
combined.set(encryptedData, iv.length + authTag.length);
let binary = '';
for (let i = 0; i < combined.length; i++) {
binary += String.fromCharCode(combined[i]);
}
return btoa(binary);
}
+9 -17
View File
@@ -186,11 +186,7 @@ export interface SkillSnapshot {
skill_id: string;
name: string;
status: unknown;
tools: Array<{
name: string;
description: string;
input_schema?: unknown;
}>;
tools: Array<{ name: string; description: string; input_schema?: unknown }>;
error?: string | null;
state?: Record<string, unknown>;
}
@@ -308,7 +304,11 @@ export interface TunnelConfig {
cloudflare?: { token: string } | null;
tailscale?: { funnel?: boolean; hostname?: string | null } | null;
ngrok?: { auth_token: string; domain?: string | null } | null;
custom?: { start_command: string; health_url?: string | null; url_pattern?: string | null } | null;
custom?: {
start_command: string;
health_url?: string | null;
url_pattern?: string | null;
} | null;
}
export async function alphahumanGetConfig(): Promise<CommandResponse<ConfigSnapshot>> {
@@ -405,9 +405,7 @@ export async function alphahumanAgentChat(
});
}
export async function alphahumanEncryptSecret(
plaintext: string
): Promise<CommandResponse<string>> {
export async function alphahumanEncryptSecret(plaintext: string): Promise<CommandResponse<string>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
@@ -437,10 +435,7 @@ export async function alphahumanDoctorModels(
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('alphahuman_doctor_models', {
providerOverride,
useCache,
});
return await invoke('alphahuman_doctor_models', { providerOverride, useCache });
}
export async function alphahumanListIntegrations(): Promise<CommandResponse<IntegrationInfo[]>> {
@@ -476,10 +471,7 @@ export async function alphahumanMigrateOpenclaw(
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke('alphahuman_migrate_openclaw', {
sourceWorkspace,
dryRun,
});
return await invoke('alphahuman_migrate_openclaw', { sourceWorkspace, dryRun });
}
export async function alphahumanHardwareDiscover(): Promise<CommandResponse<DiscoveredDevice[]>> {
+10 -3
View File
@@ -27,7 +27,14 @@ export const isTauri = (): boolean => {
const isTauriEnv = coreIsTauri();
const windowTauri = typeof window !== 'undefined' ? !!window.__TAURI__ : 'undefined';
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : 'undefined';
console.log('[TauriSocket] isTauri() check:', isTauriEnv, 'window.__TAURI__:', windowTauri, 'userAgent:', userAgent);
console.log(
'[TauriSocket] isTauri() check:',
isTauriEnv,
'window.__TAURI__:',
windowTauri,
'userAgent:',
userAgent
);
return isTauriEnv;
};
@@ -174,8 +181,8 @@ export async function setupTauriSocketListeners(): Promise<void> {
// Listen for forwarded server events
console.log('[TauriSocket] Setting up server:event listener');
unlistenServerEvent = await listen<{ event: string; data: unknown }>('server:event', event => {
console.log('[TauriSocket] Server event:', event.payload.event, event.payload.data);
// Future: dispatch to specific handlers based on event type
const { event: eventName, data } = event.payload;
console.log('[TauriSocket] Server event:', eventName, data);
});
console.log('[TauriSocket] server:event listener setup complete');
+49
View File
@@ -928,6 +928,28 @@
outvariant "^1.4.3"
strict-event-emitter "^0.5.1"
"@noble/curves@~1.9.0":
version "1.9.7"
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951"
integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==
dependencies:
"@noble/hashes" "1.8.0"
"@noble/hashes@1.8.0", "@noble/hashes@~1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a"
integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==
"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e"
integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==
"@noble/secp256k1@^2.0.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-2.3.0.tgz#ddfe6e853472fb88cba4d5e59b7067adc1e64adf"
integrity sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
@@ -1157,6 +1179,33 @@
resolved "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
"@scure/base@2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98"
integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==
"@scure/base@~1.2.5":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.6.tgz#ca917184b8231394dd8847509c67a0be522e59f6"
integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==
"@scure/bip32@^1.5.1":
version "1.7.0"
resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.7.0.tgz#b8683bab172369f988f1589640e53c4606984219"
integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==
dependencies:
"@noble/curves" "~1.9.0"
"@noble/hashes" "~1.8.0"
"@scure/base" "~1.2.5"
"@scure/bip39@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add"
integrity sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==
dependencies:
"@noble/hashes" "2.0.1"
"@scure/base" "2.0.0"
"@sec-ant/readable-stream@^0.4.1":
version "0.4.1"
resolved "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz"