mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
+1
-1
@@ -1,3 +1,3 @@
|
||||
[submodule "skills"]
|
||||
path = skills
|
||||
url = git@github.com:vezuresdotxyz/alphahuman-skills.git
|
||||
url = https://github.com/vezuresdotxyz/alphahuman-skills.git
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
if let Err(e) = handle_js_void_call(rt, ctx, "onLoad", ¶ms_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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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={
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -48,7 +48,7 @@ const Onboarding = () => {
|
||||
if (user?._id) {
|
||||
dispatch(setOnboardedForUser({ userId: user._id, value: true }));
|
||||
}
|
||||
navigate('/home');
|
||||
navigate('/mnemonic');
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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', {});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[]>> {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user