mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
refactor: remove dead frontend modules and obsolete API layers (#469)
* refactor: remove unused socket, agent tool registry, daemon health, and API service files - Deleted the `useSocket` hook, `AgentToolRegistry`, `DaemonHealthService`, and various API service files including `actionableItemsApi`, `apiKeysApi`, `feedbackApi`, `inferenceApi`, `managedDmApi`, and `settingsApi`. - This cleanup reduces code complexity and improves maintainability by removing obsolete components that are no longer in use. * feat: add knip configuration and commands to package.json - Introduced new scripts for running knip in development and production modes in both package.json files. - Added a knip.json configuration file to specify entry points and project files for dependency analysis. - Updated yarn.lock to include new dependencies related to knip, enhancing the project's dependency management capabilities. This commit improves the project's tooling for managing dependencies and ensures better code quality through automated checks. * refactor: remove unused components and clean up dependencies - Deleted several unused components related to intelligence features, including ActionPanel, InputGroup, SectionCard, and ValidatedField, to streamline the codebase. - Removed mock data and country data files that are no longer in use, enhancing maintainability. - Cleaned up the package.json by removing the @heroicons/react dependency, which is no longer required. - This commit improves the overall project structure and reduces complexity by eliminating obsolete code. * refactor: remove IntelligenceApiService and redefine ConnectedTool interface - Deleted the `IntelligenceApiService` class and its associated backend API methods to streamline the codebase. - Introduced a local definition of the `ConnectedTool` interface in `useIntelligenceApiFallback.ts` for better encapsulation and clarity. - This refactor enhances maintainability by eliminating unused code and consolidating relevant types within the appropriate context. * style: format knip config * chore(dependencies): update OpenHuman to version 0.52.0 in Cargo.lock * feat: implement Daemon Health Service for polling and state management - Introduced the `DaemonHealthService` class to poll the Rust core health snapshot and synchronize the frontend daemon store. - Added methods for setting up a health listener, parsing health snapshots, and updating the daemon store based on health data. - Implemented a timeout mechanism to handle disconnection scenarios, enhancing the reliability of the daemon's health monitoring. - This addition improves the application's ability to maintain an accurate representation of the daemon's health status in real-time. * chore(knip): update entry points in knip configuration - Modified the `entry` field in `knip.json` to include `src/main.tsx` alongside existing test specifications. - This change ensures that the main application file is included in dependency analysis, improving project structure and tooling. * refactor: streamline code and enhance readability across multiple modules - Consolidated multiple `replace` calls into single calls using arrays for improved efficiency in text processing. - Simplified default implementations for several structs, removing redundant code. - Updated query mapping in database interactions to enhance clarity and maintainability. - Improved logging and error handling by refining how state and error messages are processed. - Enhanced the readability of various functions by restructuring conditional checks and simplifying logic. These changes collectively improve code maintainability and performance across the application. * Merge remote-tracking branch 'origin/fix/cleanup' into fix/cleanup * fix: update error handling in bootstrap_after_login function - Changed the error parameter in the inspect_err closure to an underscore to indicate it is unused. - This minor adjustment improves code clarity and adheres to Rust conventions for unused variables.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/main.tsx", "test/e2e/specs/**/*.spec.ts"],
|
||||
"project": [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"test/**/*.{ts,tsx}",
|
||||
"test/e2e/globals.d.ts",
|
||||
"*.config.{js,ts}"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@tauri-apps/cli",
|
||||
"@testing-library/dom",
|
||||
"@wdio/appium-service",
|
||||
"@wdio/cli",
|
||||
"@wdio/local-runner",
|
||||
"@wdio/mocha-framework",
|
||||
"@wdio/spec-reporter",
|
||||
"buffer",
|
||||
"eslint",
|
||||
"husky",
|
||||
"os-browserify",
|
||||
"prettier",
|
||||
"process",
|
||||
"util"
|
||||
],
|
||||
"ignoreBinaries": ["eslint", "knip", "open", "prettier", "tauri", "tsc", "vite", "vitest"]
|
||||
}
|
||||
+2
-2
@@ -44,10 +44,10 @@
|
||||
"format:check": "prettier --check . && yarn rust:format:check",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix",
|
||||
"knip": "knip"
|
||||
"knip": "knip --config knip.json",
|
||||
"knip:production": "knip --config knip.json --production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@noble/secp256k1": "^3.0.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Global test setup — runs before every test file.
|
||||
*
|
||||
* - Silences console output during tests (unless DEBUG_TESTS=1)
|
||||
* - Resets rate limiter module-level state between tests
|
||||
*/
|
||||
import { beforeEach, vi } from 'vitest';
|
||||
|
||||
// Silence console during tests to keep output clean
|
||||
if (!process.env.DEBUG_TESTS) {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
}
|
||||
|
||||
// Reset rate limiter per-request counter before each test.
|
||||
// The module keeps mutable state (callHistory, lastCallTime, callsInCurrentRequest)
|
||||
// that leaks between tests if not cleared.
|
||||
beforeEach(async () => {
|
||||
try {
|
||||
const { resetRequestCallCount } = await import('../lib/mcp/rateLimiter');
|
||||
if (typeof resetRequestCallCount === 'function') {
|
||||
resetRequestCallCount();
|
||||
}
|
||||
} catch {
|
||||
// Module may be fully mocked in some test files — safe to skip
|
||||
}
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
const ExchangeIcon = ({ className = 'w-5 h-5' }: { className?: string }) => {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExchangeIcon;
|
||||
@@ -1,10 +0,0 @@
|
||||
const GmailIcon = ({ className = 'w-4 h-4' }: { className?: string }) => {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M24 5.5v13.05c0 .85-.73 1.59-1.59 1.59H1.59C.73 21.14 0 20.4 0 19.55V5.5L12 13.25 24 5.5zM24 4.5c0-.42-.2-.83-.53-1.09L12 11.25.53 3.41C.2 3.67 0 4.08 0 4.5v.75L12 13 24 5.25V4.5z" />
|
||||
<path d="M5.5 4.5L12 9.75 18.5 4.5H5.5z" opacity="0.3" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default GmailIcon;
|
||||
@@ -1,9 +0,0 @@
|
||||
const WalletIcon = ({ className = 'w-5 h-5' }: { className?: string }) => {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalletIcon;
|
||||
@@ -1,394 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Design System Showcase Component
|
||||
*
|
||||
* This component demonstrates the premium design system created for the crypto platform.
|
||||
* It showcases all the major components and styles that capture the calm, trustworthy
|
||||
* aesthetic from the reference screenshots while elevating it for professional crypto users.
|
||||
*/
|
||||
|
||||
const DesignSystemShowcase: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-canvas-50 via-canvas-100 to-primary-50/20">
|
||||
{/* Hero Section - Similar to Screenshot #1 but elevated */}
|
||||
<section className="relative overflow-hidden">
|
||||
{/* Atmospheric Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary-50/30 via-transparent to-transparent" />
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-accent-sky/20 rounded-full filter blur-3xl" />
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-accent-lavender/20 rounded-full filter blur-3xl" />
|
||||
|
||||
<div className="relative z-10 container mx-auto px-6 py-24">
|
||||
{/* Greeting Section */}
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="text-6xl font-display font-bold text-stone-900 mb-4">
|
||||
Good afternoon,
|
||||
<span className="text-gradient"> Cyrus</span>
|
||||
</h1>
|
||||
<p className="text-xl text-stone-600 mb-8">Your portfolio is up 12.5% today</p>
|
||||
|
||||
{/* Status Card */}
|
||||
<div className="glass-surface inline-flex items-center space-x-4 px-6 py-4 rounded-2xl">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="status-indicator online">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-sage-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-sage-500"></span>
|
||||
</div>
|
||||
<span className="text-stone-700 font-medium">Markets Open</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-stone-300" />
|
||||
<span className="text-stone-600">26°C and clear</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-12 flex flex-wrap gap-4">
|
||||
<button className="btn-premium">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Start Trading
|
||||
</button>
|
||||
<button className="btn-glass">View Portfolio</button>
|
||||
<button className="btn-outline">Market Analysis</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Color Palette Section */}
|
||||
<section className="container mx-auto px-6 py-16">
|
||||
<h2 className="text-3xl font-display font-bold text-stone-900 mb-8">Color System</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Primary Colors */}
|
||||
<div className="card-elevated p-6">
|
||||
<h3 className="text-lg font-semibold text-stone-900 mb-4">Primary Ocean</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-primary-500 rounded-xl shadow-soft" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-900">Primary 500</p>
|
||||
<p className="text-xs text-stone-500">#5B9BF3</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-primary-600 rounded-xl shadow-soft" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-900">Primary 600</p>
|
||||
<p className="text-xs text-stone-500">#4A83DD</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Colors */}
|
||||
<div className="card-elevated p-6">
|
||||
<h3 className="text-lg font-semibold text-stone-900 mb-4">Sage Success</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-sage-500 rounded-xl shadow-soft" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-900">Sage 500</p>
|
||||
<p className="text-xs text-stone-500">#4DC46F</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-sage-600 rounded-xl shadow-soft" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-900">Sage 600</p>
|
||||
<p className="text-xs text-stone-500">#3BA858</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent Colors */}
|
||||
<div className="card-elevated p-6">
|
||||
<h3 className="text-lg font-semibold text-stone-900 mb-4">Accent Palette</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-accent-lavender rounded-xl shadow-soft" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-900">Lavender</p>
|
||||
<p className="text-xs text-stone-500">#9B8AFB</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-accent-sky rounded-xl shadow-soft" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-900">Sky</p>
|
||||
<p className="text-xs text-stone-500">#7DD3FC</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Typography Section */}
|
||||
<section className="container mx-auto px-6 py-16">
|
||||
<h2 className="text-3xl font-display font-bold text-stone-900 mb-8">Typography Scale</h2>
|
||||
|
||||
<div className="card-elevated p-8 space-y-6">
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 mb-1">Display · 4.5rem</p>
|
||||
<h1 className="text-7xl font-display font-bold text-stone-900">Beautiful Typography</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 mb-1">Heading 1 · 3rem</p>
|
||||
<h2 className="text-5xl font-display font-semibold text-stone-900">
|
||||
Clear Information Hierarchy
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 mb-1">Heading 2 · 1.875rem</p>
|
||||
<h3 className="text-3xl font-display font-semibold text-stone-900">
|
||||
Structured Content Design
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 mb-1">Body · 1rem</p>
|
||||
<p className="text-base text-stone-700 leading-relaxed max-w-3xl">
|
||||
The typography system is designed to create clear visual hierarchy while maintaining
|
||||
excellent readability. Each text size has been carefully calibrated with appropriate
|
||||
line heights and letter spacing to ensure optimal legibility across all device sizes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 mb-1">Monospace · For prices and data</p>
|
||||
<p className="text-crypto-price text-2xl text-stone-900">
|
||||
$48,392.50 <span className="text-market-bullish">+2.45%</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Card Components Section */}
|
||||
<section className="container mx-auto px-6 py-16">
|
||||
<h2 className="text-3xl font-display font-bold text-stone-900 mb-8">Card Components</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Market Card */}
|
||||
<div className="market-card">
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-market-bitcoin/20 rounded-xl flex items-center justify-center">
|
||||
<span className="text-market-bitcoin font-bold">₿</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">Bitcoin</p>
|
||||
<p className="text-sm text-stone-500">BTC</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge-premium">Trending</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="price-ticker up">$48,392.50</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-market-bullish font-medium">+$1,185.20</span>
|
||||
<span className="text-sm text-market-bullish">(+2.45%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Card */}
|
||||
<div className="card-interactive">
|
||||
<h3 className="text-lg font-semibold text-stone-900 mb-2">Portfolio Overview</h3>
|
||||
<p className="text-stone-600 mb-4">
|
||||
Track your investments and monitor performance in real-time.
|
||||
</p>
|
||||
<div className="flex items-center text-primary-600 font-medium">
|
||||
<span>View Details</span>
|
||||
<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Elevated Card */}
|
||||
<div className="card-elevated">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-stone-900">Quick Stats</h3>
|
||||
<div className="w-8 h-8 bg-primary-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-4 h-4 text-primary-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-stone-600">Total Value</span>
|
||||
<span className="font-mono font-semibold text-stone-900">$125,430</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-stone-600">24h Change</span>
|
||||
<span className="font-mono font-semibold text-market-bullish">+3.2%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-stone-600">Holdings</span>
|
||||
<span className="font-mono font-semibold text-stone-900">12</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Navigation Pattern - Similar to Screenshot #2 */}
|
||||
<section className="container mx-auto px-6 py-16">
|
||||
<h2 className="text-3xl font-display font-bold text-stone-900 mb-8">
|
||||
Navigation & Settings
|
||||
</h2>
|
||||
|
||||
<div className="max-w-md">
|
||||
<div className="card-elevated p-2">
|
||||
{/* User Profile Section */}
|
||||
<div className="p-4 border-b border-stone-200">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-primary-400 to-accent-lavender rounded-xl" />
|
||||
<div>
|
||||
<p className="font-semibold text-stone-900">Cyrus Amini</p>
|
||||
<p className="text-sm text-stone-500">Premium Member</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="p-2">
|
||||
<a href="#" className="nav-item-premium active">
|
||||
<svg className="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="#" className="nav-item-premium">
|
||||
<svg className="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Profile Settings
|
||||
</a>
|
||||
<a href="#" className="nav-item-premium">
|
||||
<svg className="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
Notifications
|
||||
</a>
|
||||
<a href="#" className="nav-item-premium">
|
||||
<svg className="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
Security
|
||||
</a>
|
||||
<a href="#" className="nav-item-premium">
|
||||
<svg className="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Help & Support
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Form Elements */}
|
||||
<section className="container mx-auto px-6 py-16">
|
||||
<h2 className="text-3xl font-display font-bold text-stone-900 mb-8">Form Elements</h2>
|
||||
|
||||
<div className="max-w-2xl">
|
||||
<div className="card-elevated p-8 space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">Email Address</label>
|
||||
<input type="email" className="input-elevated" placeholder="Enter your email" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">
|
||||
Investment Amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-3 text-stone-500">$</span>
|
||||
<input type="text" className="input-elevated pl-8" placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">
|
||||
Select Currency
|
||||
</label>
|
||||
<select className="input-elevated">
|
||||
<option>Bitcoin (BTC)</option>
|
||||
<option>Ethereum (ETH)</option>
|
||||
<option>USDT</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
className="w-4 h-4 text-primary-600 border-stone-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="terms" className="text-sm text-stone-700">
|
||||
I agree to the terms and conditions
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className="btn-premium w-full">Complete Transaction</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DesignSystemShowcase;
|
||||
@@ -1,34 +0,0 @@
|
||||
import GmailIcon from '../assets/icons/GmailIcon';
|
||||
|
||||
interface GmailConnectionIndicatorProps {
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const GmailConnectionIndicator = ({
|
||||
description,
|
||||
className = '',
|
||||
}: GmailConnectionIndicatorProps) => {
|
||||
// Gmail is always offline for now (placeholder)
|
||||
const gmailIsOnline = false;
|
||||
|
||||
return (
|
||||
<div className={`mb-6 ${className}`}>
|
||||
<div className="flex items-center justify-center space-x-2 mb-3">
|
||||
<div
|
||||
className={`w-2 h-2 ${gmailIsOnline ? 'bg-red-500' : 'bg-gray-500'} rounded-full ${gmailIsOnline ? 'animate-pulse' : ''}`}></div>
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<GmailIcon className={`w-4 h-4 ${gmailIsOnline ? 'text-red-500' : 'text-gray-500'}`} />
|
||||
<span className={`text-sm ${gmailIsOnline ? 'text-red-500' : 'text-gray-500'}`}>
|
||||
{gmailIsOnline ? 'Connected to Gmail' : 'Gmail is Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs opacity-60 text-center leading-relaxed">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GmailConnectionIndicator;
|
||||
@@ -1,242 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { isTauri } from '../utils/tauriCommands';
|
||||
import DaemonHealthIndicator from './daemon/DaemonHealthIndicator';
|
||||
import DaemonHealthPanel from './daemon/DaemonHealthPanel';
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
path: '/home',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a2 2 0 01-2-2v-4a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Skills',
|
||||
path: '/skills',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14 10l-2 1m0 0l-2-1m2 1v2.5M20 7l-2 1m2-1l-2-1m2 1v2.5M14 4l-2-1-2 1M4 7l2-1M4 7l2 1M4 7v2.5M12 21l-2-1m2 1l2-1m-2 1v-2.5M6 18l-2-1v-2.5M18 18l2-1v-2.5"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'conversations',
|
||||
label: 'Conversations',
|
||||
path: '/conversations',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'intelligence',
|
||||
label: 'Intelligence',
|
||||
path: '/intelligence',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// {
|
||||
// id: 'invites',
|
||||
// label: 'Invite Friends',
|
||||
// path: '/invites',
|
||||
// icon: (
|
||||
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
// <path
|
||||
// strokeLinecap="round"
|
||||
// strokeLinejoin="round"
|
||||
// strokeWidth={2}
|
||||
// d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
// />
|
||||
// </svg>
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
path: '/settings',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'channels',
|
||||
label: 'Channels',
|
||||
path: '/channels',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M21 11c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 19l1.395-3.72C3.512 14.042 3 12.574 3 11c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'cron-jobs',
|
||||
label: 'Cron Jobs',
|
||||
path: '/settings/cron-jobs',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const MiniSidebar = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { snapshot } = useCoreState();
|
||||
const token = snapshot.sessionToken;
|
||||
const [showDaemonPanel, setShowDaemonPanel] = useState(false);
|
||||
|
||||
// Unread count for Conversations: threads with lastMessageAt > lastViewedAt (must be before early return)
|
||||
const conversationsUnreadCount = useAppSelector(state => {
|
||||
const { threads, lastViewedAt } = state.thread;
|
||||
if (threads.length === 0) return 0;
|
||||
return threads.filter(t => {
|
||||
const viewed = lastViewedAt[t.id];
|
||||
const lastMsg = new Date(t.lastMessageAt || t.createdAt).getTime();
|
||||
return viewed == null || lastMsg > viewed;
|
||||
}).length;
|
||||
});
|
||||
|
||||
// Hide sidebar on public/setup routes and when not authenticated.
|
||||
const hiddenPaths = ['/', '/login'];
|
||||
if (
|
||||
!token ||
|
||||
hiddenPaths.some(path => location.pathname === path || location.pathname.startsWith(`${path}/`))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/settings') {
|
||||
return (
|
||||
location.pathname === '/settings' ||
|
||||
(location.pathname.startsWith('/settings/') &&
|
||||
!location.pathname.startsWith('/settings/cron-jobs'))
|
||||
);
|
||||
}
|
||||
if (path === '/channels') return location.pathname.startsWith('/channels');
|
||||
if (path === '/settings/cron-jobs') return location.pathname.startsWith('/settings/cron-jobs');
|
||||
if (path === '/conversations') return location.pathname.startsWith('/conversations');
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-14 flex-shrink-0 bg-black backdrop-blur-md border-r border-white/10 flex flex-col items-center py-4 gap-2 z-50 relative">
|
||||
{/* Navigation Items */}
|
||||
<div className="flex flex-col items-center gap-2 flex-1">
|
||||
{navItems.map(item => {
|
||||
const active = isActive(item.path);
|
||||
const showUnreadBadge = item.id === 'conversations' && conversationsUnreadCount > 0;
|
||||
return (
|
||||
<div key={item.id} className="relative group">
|
||||
<button
|
||||
onClick={() => navigate(item.path)}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all duration-200 cursor-pointer ${
|
||||
active
|
||||
? 'bg-white/10 text-black'
|
||||
: 'text-stone-500 hover:text-stone-300 hover:bg-white/5'
|
||||
}`}
|
||||
aria-label={item.label}>
|
||||
{item.icon}
|
||||
</button>
|
||||
{showUnreadBadge && (
|
||||
<span
|
||||
className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center rounded-full bg-primary-500 text-white text-[10px] font-medium"
|
||||
aria-label={`${conversationsUnreadCount} unread`}>
|
||||
{conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Tooltip - appears to the right */}
|
||||
<div className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 bg-stone-800 text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
{item.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Daemon Health Indicator - Only show in Tauri mode */}
|
||||
{isTauri() && (
|
||||
<div className="relative group">
|
||||
<div className="w-10 h-10 flex items-center justify-center rounded-xl text-stone-500 hover:text-stone-300 hover:bg-white/5 transition-all duration-200 cursor-pointer">
|
||||
<DaemonHealthIndicator size="md" onClick={() => setShowDaemonPanel(true)} />
|
||||
</div>
|
||||
{/* Tooltip */}
|
||||
<div className="pointer-events-none absolute left-full top-1/2 -translate-y-1/2 ml-2 px-2 py-1 bg-stone-800 text-white text-xs rounded-lg whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
Agent Status
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daemon Health Panel Modal */}
|
||||
{showDaemonPanel && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[9999]">
|
||||
<div className="max-w-2xl w-full mx-4">
|
||||
<DaemonHealthPanel onClose={() => setShowDaemonPanel(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniSidebar;
|
||||
@@ -1,19 +0,0 @@
|
||||
interface PrivacyFeatureCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PrivacyFeatureCard = ({ title, description }: PrivacyFeatureCardProps) => {
|
||||
return (
|
||||
<div className="bg-stone-800/50 rounded-xl p-3 border border-stone-700">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm mb-2 text-center">{title}</h3>
|
||||
<p className="opacity-70 text-xs leading-relaxed text-center">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyFeatureCard;
|
||||
@@ -1,45 +0,0 @@
|
||||
import TelegramIcon from '../assets/icons/telegram.svg';
|
||||
import { useSkillConnectionStatus } from '../lib/skills/hooks';
|
||||
import type { SkillConnectionStatus } from '../lib/skills/types';
|
||||
|
||||
interface TelegramConnectionIndicatorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<SkillConnectionStatus, { dot: string; text: string; label: string }> = {
|
||||
connected: { dot: 'bg-sage-500', text: 'text-sage-500', label: 'Telegram Connected' },
|
||||
connecting: { dot: 'bg-amber-500', text: 'text-amber-500', label: 'Telegram Connecting...' },
|
||||
not_authenticated: {
|
||||
dot: 'bg-amber-500',
|
||||
text: 'text-amber-500',
|
||||
label: 'Telegram Not Authenticated',
|
||||
},
|
||||
disconnected: { dot: 'bg-gray-500', text: 'text-gray-500', label: 'Telegram Disconnected' },
|
||||
error: { dot: 'bg-coral-500', text: 'text-coral-500', label: 'Telegram Error' },
|
||||
offline: { dot: 'bg-gray-500', text: 'text-gray-500', label: 'Telegram Offline' },
|
||||
setup_required: { dot: 'bg-gray-500', text: 'text-gray-500', label: 'Telegram Not Set Up' },
|
||||
};
|
||||
|
||||
const TelegramConnectionIndicator = ({ className = '' }: TelegramConnectionIndicatorProps) => {
|
||||
const status = useSkillConnectionStatus('telegram');
|
||||
const config = STATUS_CONFIG[status];
|
||||
const isActive = status === 'connected';
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className={`w-2 h-2 ${config.dot} rounded-full ${isActive ? 'animate-pulse' : ''}`} />
|
||||
<div className="flex items-center space-x-1.5">
|
||||
<img
|
||||
src={TelegramIcon}
|
||||
alt="Telegram"
|
||||
className={`w-4 h-4 ${isActive ? 'opacity-100' : 'opacity-50'}`}
|
||||
/>
|
||||
<span className={`text-sm ${config.text}`}>{config.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TelegramConnectionIndicator;
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface TypewriterGreetingProps {
|
||||
greetings: string[];
|
||||
typingSpeed?: number;
|
||||
deletingSpeed?: number;
|
||||
pauseTime?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TypewriterGreeting = ({
|
||||
greetings,
|
||||
typingSpeed = 100,
|
||||
deletingSpeed = 50,
|
||||
pauseTime = 2000,
|
||||
className = '',
|
||||
}: TypewriterGreetingProps) => {
|
||||
const [currentGreetingIndex, setCurrentGreetingIndex] = useState(0);
|
||||
const [displayedText, setDisplayedText] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (greetings.length === 0) return;
|
||||
|
||||
const currentGreeting = greetings[currentGreetingIndex];
|
||||
const speed = isDeleting ? deletingSpeed : typingSpeed;
|
||||
|
||||
if (isPaused) {
|
||||
const pauseTimer = setTimeout(() => {
|
||||
setIsPaused(false);
|
||||
setIsDeleting(true);
|
||||
}, pauseTime);
|
||||
return () => clearTimeout(pauseTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!isDeleting) {
|
||||
// Typing
|
||||
if (displayedText.length < currentGreeting.length) {
|
||||
setDisplayedText(currentGreeting.slice(0, displayedText.length + 1));
|
||||
} else {
|
||||
// Finished typing, pause before deleting
|
||||
setIsPaused(true);
|
||||
}
|
||||
} else {
|
||||
// Deleting
|
||||
if (displayedText.length > 0) {
|
||||
setDisplayedText(displayedText.slice(0, -1));
|
||||
} else {
|
||||
// Finished deleting, move to next greeting
|
||||
setIsDeleting(false);
|
||||
setCurrentGreetingIndex(prev => (prev + 1) % greetings.length);
|
||||
}
|
||||
}
|
||||
}, speed);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
displayedText,
|
||||
isDeleting,
|
||||
isPaused,
|
||||
currentGreetingIndex,
|
||||
greetings,
|
||||
typingSpeed,
|
||||
deletingSpeed,
|
||||
pauseTime,
|
||||
]);
|
||||
|
||||
return (
|
||||
<h1 className={`text-2xl font-bold mb-2 ${className}`}>
|
||||
{displayedText}
|
||||
<span className="animate-pulse">|</span>
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypewriterGreeting;
|
||||
@@ -1,236 +0,0 @@
|
||||
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 { useCoreState } from '../providers/CoreStateProvider';
|
||||
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
|
||||
|
||||
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 { snapshot } = useCoreState();
|
||||
const primaryAddress = user?._id ? snapshot.localState.primaryWalletAddress : 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,108 +0,0 @@
|
||||
/**
|
||||
* Daemon Health Indicator
|
||||
*
|
||||
* Compact status indicator showing daemon health with a colored dot and optional label.
|
||||
* Can be clicked to show detailed health information.
|
||||
*/
|
||||
import type React from 'react';
|
||||
|
||||
import type { DaemonStatus } from '../../features/daemon/store';
|
||||
import { formatRelativeTime, useDaemonHealth } from '../../hooks/useDaemonHealth';
|
||||
|
||||
interface Props {
|
||||
userId?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DaemonHealthIndicator: React.FC<Props> = ({
|
||||
userId,
|
||||
size = 'md',
|
||||
showLabel = false,
|
||||
onClick,
|
||||
className = '',
|
||||
}) => {
|
||||
const daemonHealth = useDaemonHealth(userId);
|
||||
|
||||
// Size configurations
|
||||
const sizeConfig = {
|
||||
sm: { dot: 'w-2 h-2', text: 'text-xs', container: 'gap-1.5' },
|
||||
md: { dot: 'w-3 h-3', text: 'text-sm', container: 'gap-2' },
|
||||
lg: { dot: 'w-4 h-4', text: 'text-base', container: 'gap-2.5' },
|
||||
};
|
||||
|
||||
const config = sizeConfig[size];
|
||||
|
||||
// Status color mapping
|
||||
const getStatusColor = (status: DaemonStatus): string => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-green-500';
|
||||
case 'starting':
|
||||
return 'bg-yellow-500';
|
||||
case 'error':
|
||||
return 'bg-red-500';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// Status text mapping
|
||||
const getStatusText = (status: DaemonStatus): string => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'Running';
|
||||
case 'starting':
|
||||
return 'Starting';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'Disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
// Tooltip content
|
||||
const getTooltipContent = (): string => {
|
||||
const { status, componentCount, healthyComponentCount, errorComponentCount, lastUpdate } =
|
||||
daemonHealth;
|
||||
|
||||
let tooltip = `Status: ${getStatusText(status)}`;
|
||||
|
||||
if (componentCount > 0) {
|
||||
tooltip += `\nComponents: ${healthyComponentCount}/${componentCount} healthy`;
|
||||
if (errorComponentCount > 0) {
|
||||
tooltip += ` (${errorComponentCount} errors)`;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUpdate) {
|
||||
tooltip += `\nLast update: ${formatRelativeTime(lastUpdate)}`;
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
};
|
||||
|
||||
const statusColor = getStatusColor(daemonHealth.status);
|
||||
const statusText = getStatusText(daemonHealth.status);
|
||||
|
||||
const containerClasses = `
|
||||
flex items-center ${config.container}
|
||||
${onClick ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}
|
||||
${className}
|
||||
`.trim();
|
||||
|
||||
return (
|
||||
<div className={containerClasses} onClick={onClick} title={getTooltipContent()}>
|
||||
<div className={`${config.dot} rounded-full ${statusColor} flex-shrink-0`} />
|
||||
{showLabel && (
|
||||
<span className={`${config.text} text-gray-300 font-medium`}>{statusText}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DaemonHealthIndicator;
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* Agent Health Panel
|
||||
*
|
||||
* Detailed health breakdown component showing agent status, component health,
|
||||
* and providing manual control buttons for agent lifecycle management.
|
||||
*/
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
XCircleIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { ComponentHealth, DaemonStatus } from '../../features/daemon/store';
|
||||
import { formatRelativeTime, useDaemonHealth } from '../../hooks/useDaemonHealth';
|
||||
import { IS_DEV } from '../../utils/config';
|
||||
import {
|
||||
isTauri,
|
||||
openhumanGetDaemonHostConfig,
|
||||
openhumanSetDaemonHostConfig,
|
||||
} from '../../utils/tauriCommands';
|
||||
|
||||
interface Props {
|
||||
userId?: string;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DaemonHealthPanel = ({ userId, onClose, className = '' }: Props) => {
|
||||
const daemonHealth = useDaemonHealth(userId);
|
||||
const [operationLoading, setOperationLoading] = useState<string | null>(null);
|
||||
const [showTray, setShowTray] = useState<boolean>(true);
|
||||
const [trayLoaded, setTrayLoaded] = useState<boolean>(false);
|
||||
|
||||
// Handle agent operations with loading states
|
||||
const handleOperation = async (operation: () => Promise<unknown>, operationName: string) => {
|
||||
setOperationLoading(operationName);
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
console.error(`[AgentHealthPanel] ${operationName} failed:`, error);
|
||||
} finally {
|
||||
setOperationLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Status styling
|
||||
const getStatusStyling = (status: DaemonStatus) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
bg: 'bg-green-900/20 border-green-500/30',
|
||||
text: 'text-green-400',
|
||||
icon: CheckCircleIcon,
|
||||
};
|
||||
case 'starting':
|
||||
return {
|
||||
bg: 'bg-yellow-900/20 border-yellow-500/30',
|
||||
text: 'text-yellow-400',
|
||||
icon: ClockIcon,
|
||||
};
|
||||
case 'error':
|
||||
return { bg: 'bg-red-900/20 border-red-500/30', text: 'text-red-400', icon: XCircleIcon };
|
||||
case 'disconnected':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-gray-900/20 border-gray-500/30',
|
||||
text: 'text-gray-400',
|
||||
icon: XCircleIcon,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Component status styling
|
||||
const getComponentStyling = (component: ComponentHealth) => {
|
||||
switch (component.status) {
|
||||
case 'ok':
|
||||
return { bg: 'bg-green-500', text: 'text-green-400', icon: CheckCircleIcon };
|
||||
case 'error':
|
||||
return { bg: 'bg-red-500', text: 'text-red-400', icon: XCircleIcon };
|
||||
case 'starting':
|
||||
return { bg: 'bg-yellow-500', text: 'text-yellow-400', icon: ClockIcon };
|
||||
}
|
||||
};
|
||||
|
||||
const statusStyling = getStatusStyling(daemonHealth.status);
|
||||
const StatusIcon = statusStyling.icon;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
const loadTray = async () => {
|
||||
try {
|
||||
const result = await openhumanGetDaemonHostConfig();
|
||||
setShowTray(result.result.show_tray);
|
||||
setTrayLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('[AgentHealthPanel] Failed to load daemon host config:', error);
|
||||
}
|
||||
};
|
||||
void loadTray();
|
||||
}, []);
|
||||
|
||||
const updateTraySetting = async (enabled: boolean) => {
|
||||
if (!isTauri()) {
|
||||
return;
|
||||
}
|
||||
const previous = showTray;
|
||||
setShowTray(enabled);
|
||||
setOperationLoading('tray');
|
||||
try {
|
||||
await openhumanSetDaemonHostConfig(enabled);
|
||||
} catch (error) {
|
||||
setShowTray(previous);
|
||||
console.error('[AgentHealthPanel] Failed to save daemon host config:', error);
|
||||
} finally {
|
||||
setOperationLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-stone-900 rounded-lg border border-stone-700 p-6 space-y-6 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">Agent Status</h3>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overall Status */}
|
||||
<div className={`p-4 rounded-lg border ${statusStyling.bg}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusIcon className={`w-6 h-6 ${statusStyling.text}`} />
|
||||
<div>
|
||||
<div className={`font-medium ${statusStyling.text}`}>
|
||||
Status: {daemonHealth.status.charAt(0).toUpperCase() + daemonHealth.status.slice(1)}
|
||||
</div>
|
||||
{daemonHealth.healthSnapshot && (
|
||||
<div className="text-sm text-gray-400">
|
||||
PID: {daemonHealth.healthSnapshot.pid} • Uptime: {daemonHealth.uptimeText}
|
||||
</div>
|
||||
)}
|
||||
{daemonHealth.lastUpdate && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Last update: {formatRelativeTime(daemonHealth.lastUpdate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recovery indicator */}
|
||||
{daemonHealth.isRecovering && (
|
||||
<div className="flex items-center gap-2 text-yellow-400">
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">Recovering...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component Health */}
|
||||
{daemonHealth.componentCount > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-300">
|
||||
Components ({daemonHealth.healthyComponentCount}/{daemonHealth.componentCount} healthy)
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Object.entries(daemonHealth.components).map(([name, component]) => {
|
||||
const componentStyling = getComponentStyling(component);
|
||||
const ComponentIcon = componentStyling.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-stone-800/40 border border-stone-700/60">
|
||||
<div className={`w-2 h-2 rounded-full ${componentStyling.bg}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ComponentIcon className={`w-4 h-4 ${componentStyling.text}`} />
|
||||
<span className="capitalize text-gray-300 font-medium">{name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Updated: {formatRelativeTime(component.updated_at)}
|
||||
</div>
|
||||
{component.restart_count > 0 && (
|
||||
<div className="text-xs text-yellow-400">
|
||||
Restarts: {component.restart_count}
|
||||
</div>
|
||||
)}
|
||||
{component.last_error && component.status === 'error' && (
|
||||
<div className="text-xs text-red-400 truncate" title={component.last_error}>
|
||||
Error: {component.last_error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 Agent</div>
|
||||
<div className="text-xs text-gray-500">Automatically start agent 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>
|
||||
|
||||
<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">Show Daemon Tray</div>
|
||||
<div className="text-xs text-gray-500">Display tray icon in daemon host mode</div>
|
||||
<div className="text-xs text-amber-400 mt-1">Requires daemon host restart to apply</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={showTray}
|
||||
disabled={!trayLoaded || operationLoading !== null}
|
||||
onChange={e => {
|
||||
void updateTraySetting(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-disabled:opacity-60 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>
|
||||
|
||||
{/* Control Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => handleOperation(daemonHealth.startDaemon, 'start')}
|
||||
disabled={daemonHealth.status === 'running' || operationLoading !== null}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg transition-colors">
|
||||
{operationLoading === 'start' ? (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<PlayIcon className="w-4 h-4" />
|
||||
)}
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOperation(daemonHealth.stopDaemon, 'stop')}
|
||||
disabled={daemonHealth.status === 'disconnected' || operationLoading !== null}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg transition-colors">
|
||||
{operationLoading === 'stop' ? (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<StopIcon className="w-4 h-4" />
|
||||
)}
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOperation(daemonHealth.restartDaemon, 'restart')}
|
||||
disabled={operationLoading !== null}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg transition-colors">
|
||||
{operationLoading === 'restart' ? (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
)}
|
||||
Restart
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOperation(daemonHealth.checkDaemonStatus, 'check')}
|
||||
disabled={operationLoading !== null}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-300 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:cursor-not-allowed rounded-lg transition-colors">
|
||||
{operationLoading === 'check' ? (
|
||||
<ArrowPathIcon className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowPathIcon className="w-4 h-4" />
|
||||
)}
|
||||
Check Status
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Debug Info (development only) */}
|
||||
{IS_DEV && daemonHealth.healthSnapshot && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-gray-400 hover:text-white">Debug Info</summary>
|
||||
<pre className="mt-2 p-3 bg-stone-800 rounded text-gray-300 overflow-x-auto">
|
||||
{JSON.stringify(daemonHealth.healthSnapshot, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DaemonHealthPanel;
|
||||
@@ -1,19 +0,0 @@
|
||||
// Intelligence Components
|
||||
// Exports for the AI-powered actionable insights dashboard
|
||||
|
||||
export { ActionableCard } from './ActionableCard';
|
||||
export { ConfirmationModal } from './ConfirmationModal';
|
||||
export { Toast, ToastContainer } from './Toast';
|
||||
export { MOCK_ACTIONABLE_ITEMS } from './mockData';
|
||||
export { groupItemsByTime, filterItems, getItemStats } from './utils';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
ActionableItem,
|
||||
ActionableItemSource,
|
||||
ActionableItemPriority,
|
||||
ActionableItemStatus,
|
||||
TimeGroup,
|
||||
ToastNotification,
|
||||
ConfirmationModal as ConfirmationModalType,
|
||||
} from '../../types/intelligence';
|
||||
@@ -1,311 +0,0 @@
|
||||
import type { ActionableItem } from '../../types/intelligence';
|
||||
|
||||
// Helper function to create dates relative to now
|
||||
function createDate(minutesAgo: number): Date {
|
||||
return new Date(Date.now() - minutesAgo * 60 * 1000);
|
||||
}
|
||||
|
||||
function createDateDays(daysAgo: number): Date {
|
||||
return new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export const MOCK_ACTIONABLE_ITEMS: ActionableItem[] = [
|
||||
// Today - Fresh items
|
||||
{
|
||||
id: '1',
|
||||
title: 'Reply to 2 critical emails expecting response within 24hrs',
|
||||
description:
|
||||
'Messages from john@coinbase.com and sarah@ethereum.org about partnership proposals',
|
||||
source: 'email',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDate(2),
|
||||
updatedAt: createDate(2),
|
||||
expiresAt: createDate(-1440), // Expires in 24 hours
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Gmail',
|
||||
},
|
||||
|
||||
{
|
||||
id: '2',
|
||||
title: 'Meeting with Sarah in 30 minutes - prepare documents?',
|
||||
description:
|
||||
'Q4 strategy meeting. Need to review the crypto market analysis and portfolio updates.',
|
||||
source: 'calendar',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDate(5),
|
||||
updatedAt: createDate(5),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'Calendar',
|
||||
},
|
||||
|
||||
{
|
||||
id: '3',
|
||||
title: 'Order lunch for your 2pm work session?',
|
||||
description:
|
||||
'You usually order from "Crypto Café" around this time. Your favorite: Bitcoin Bowl ($15.99)',
|
||||
source: 'ai_insight',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDate(15),
|
||||
updatedAt: createDate(15),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'AI Assistant',
|
||||
},
|
||||
|
||||
{
|
||||
id: '4',
|
||||
title: '5 unread messages from Alpha Trading Group',
|
||||
description:
|
||||
'@cryptowhale mentioned you about the ETH analysis. 3 other traders are discussing market trends.',
|
||||
source: 'telegram',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDate(25),
|
||||
updatedAt: createDate(25),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Telegram',
|
||||
},
|
||||
|
||||
{
|
||||
id: '5',
|
||||
title: 'Free 45min slot available for gym session',
|
||||
description: 'Your calendar shows a gap between 3:15-4:00 PM. LA Fitness has availability.',
|
||||
source: 'ai_insight',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDate(45),
|
||||
updatedAt: createDate(45),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'AI Planner',
|
||||
},
|
||||
|
||||
// Yesterday
|
||||
{
|
||||
id: '6',
|
||||
title: 'Alex mentioned you in Alpha Human Development chat',
|
||||
description: '"@john can you review the new trading algorithm?" - 3 hours ago',
|
||||
source: 'telegram',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDate(180),
|
||||
updatedAt: createDate(180),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Telegram',
|
||||
},
|
||||
|
||||
{
|
||||
id: '7',
|
||||
title: 'Update available for DeFi trading bot',
|
||||
description:
|
||||
'Version 2.1.4 includes security patches and 15% better performance. Requires restart.',
|
||||
source: 'system',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDate(360),
|
||||
updatedAt: createDate(360),
|
||||
actionable: true,
|
||||
requiresConfirmation: true,
|
||||
sourceLabel: 'System',
|
||||
},
|
||||
|
||||
{
|
||||
id: '8',
|
||||
title: 'Backup your crypto wallet keys',
|
||||
description: 'Last backup was 30 days ago. Your portfolio is up 34% since then.',
|
||||
source: 'security',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDate(420),
|
||||
updatedAt: createDate(420),
|
||||
actionable: true,
|
||||
requiresConfirmation: false,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Security',
|
||||
},
|
||||
|
||||
// This week
|
||||
{
|
||||
id: '9',
|
||||
title: 'Schedule meeting with VCs for Series A discussion',
|
||||
description: 'Follow up on last weeks pitch. Andreessen Horowitz and Sequoia are interested.',
|
||||
source: 'email',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(2),
|
||||
updatedAt: createDateDays(2),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Gmail',
|
||||
},
|
||||
|
||||
{
|
||||
id: '10',
|
||||
title: 'Review 12 pending GitHub pull requests',
|
||||
description:
|
||||
'Features for v0.21.0 release. 8 from team members, 4 from community contributors.',
|
||||
source: 'system',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(3),
|
||||
updatedAt: createDateDays(3),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'GitHub',
|
||||
},
|
||||
|
||||
{
|
||||
id: '11',
|
||||
title: 'BTC price alert: Approaching your target of $65,000',
|
||||
description: 'Currently at $64,420. You set this alert 2 weeks ago. Consider taking profits?',
|
||||
source: 'trading',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(3),
|
||||
updatedAt: createDateDays(3),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'Trading Bot',
|
||||
},
|
||||
|
||||
{
|
||||
id: '12',
|
||||
title: 'Complete KYC verification for Binance account',
|
||||
description: 'Required for withdrawal limits above $2,000. Upload ID and proof of address.',
|
||||
source: 'trading',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(4),
|
||||
updatedAt: createDateDays(4),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Binance',
|
||||
},
|
||||
|
||||
// Older items
|
||||
{
|
||||
id: '13',
|
||||
title: 'Respond to podcast interview request from Lex Fridman',
|
||||
description: 'Topic: "The Future of Decentralized Communication". Suggested dates: next month.',
|
||||
source: 'email',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(8),
|
||||
updatedAt: createDateDays(8),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Gmail',
|
||||
},
|
||||
|
||||
{
|
||||
id: '14',
|
||||
title: 'Submit tax documents for Q3 crypto transactions',
|
||||
description: '47 transactions across 5 exchanges. Deadline: October 15th.',
|
||||
source: 'system',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(10),
|
||||
updatedAt: createDateDays(10),
|
||||
expiresAt: createDate(-14400), // Expires in 10 days
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
requiresConfirmation: false,
|
||||
sourceLabel: 'TaxBot',
|
||||
},
|
||||
|
||||
{
|
||||
id: '15',
|
||||
title: 'Renew SSL certificate for api.openhuman.com',
|
||||
description:
|
||||
'Certificate expires in 5 days. Automatic renewal failed - manual intervention required.',
|
||||
source: 'system',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(12),
|
||||
updatedAt: createDateDays(12),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
requiresConfirmation: false,
|
||||
sourceLabel: 'DevOps',
|
||||
},
|
||||
|
||||
{
|
||||
id: '16',
|
||||
title: 'Plan team building event for remote employees',
|
||||
description:
|
||||
'15 team members across 8 time zones. Budget approved: $5,000. Preference: virtual escape room.',
|
||||
source: 'ai_insight',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(14),
|
||||
updatedAt: createDateDays(14),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'HR Assistant',
|
||||
},
|
||||
|
||||
// Expired/old items that should show as low priority
|
||||
{
|
||||
id: '17',
|
||||
title: 'Update LinkedIn profile with latest achievements',
|
||||
description: 'Add recent TechCrunch feature and Y Combinator alumni status.',
|
||||
source: 'ai_insight',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(20),
|
||||
updatedAt: createDateDays(20),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'Career AI',
|
||||
},
|
||||
|
||||
{
|
||||
id: '18',
|
||||
title: 'Review and approve marketing budget for Q4',
|
||||
description: '$50K allocated for influencer partnerships and conference sponsorships.',
|
||||
source: 'email',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(21),
|
||||
updatedAt: createDateDays(21),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Team Email',
|
||||
},
|
||||
|
||||
{
|
||||
id: '19',
|
||||
title: 'Check server capacity for expected user growth',
|
||||
description: 'Daily active users grew 23% this month. Current infrastructure may need scaling.',
|
||||
source: 'system',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(25),
|
||||
updatedAt: createDateDays(25),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'Monitoring',
|
||||
},
|
||||
|
||||
{
|
||||
id: '20',
|
||||
title: 'Organize crypto portfolio - consolidate across 7 wallets',
|
||||
description:
|
||||
'Assets spread across MetaMask, Ledger, Trust Wallet, and 4 others. Gas fees optimized for Sunday.',
|
||||
source: 'security',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(30),
|
||||
updatedAt: createDateDays(30),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Portfolio AI',
|
||||
},
|
||||
];
|
||||
@@ -1,98 +0,0 @@
|
||||
import { CheckIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
import React from 'react';
|
||||
|
||||
interface ActionPanelProps {
|
||||
children: React.ReactNode;
|
||||
hasChanges?: boolean;
|
||||
success?: boolean | string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ActionPanel: React.FC<ActionPanelProps> = ({
|
||||
children,
|
||||
hasChanges = false,
|
||||
success = false,
|
||||
error,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{children}
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-700">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
Unsaved changes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-sage-500/40 bg-sage-50 px-3 py-2 text-sm text-sage-700">
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{typeof success === 'string' ? success : 'Operation completed successfully'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-coral-500/40 bg-coral-50 px-3 py-2 text-sm text-coral-700">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
variant = 'primary',
|
||||
children,
|
||||
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 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-white',
|
||||
secondary:
|
||||
'bg-stone-100 hover:bg-stone-200 active:bg-stone-300 text-stone-900 border border-stone-200 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-white',
|
||||
outline:
|
||||
'border border-stone-200 text-stone-900 hover:bg-stone-100 active:bg-stone-200 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-white',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${className} flex items-center justify-center`}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<div
|
||||
className={`h-4 w-4 border-2 rounded-full animate-spin ${
|
||||
variant === 'primary'
|
||||
? 'border-white/40 border-t-white'
|
||||
: 'border-stone-600/40 border-t-stone-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionPanel;
|
||||
export { PrimaryButton };
|
||||
@@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InputGroupProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InputGroup: React.FC<InputGroupProps> = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{title && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-lg font-medium text-stone-900">{title}</h4>
|
||||
{description && <p className="text-sm text-stone-500">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-stone-200 bg-stone-50 backdrop-blur-sm p-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
helpText?: string;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const Field: React.FC<FieldProps> = ({
|
||||
label,
|
||||
children,
|
||||
helpText,
|
||||
className = '',
|
||||
fullWidth = false,
|
||||
}) => {
|
||||
return (
|
||||
<label
|
||||
className={`space-y-3 text-sm text-stone-600 ${fullWidth ? 'md:col-span-2' : ''} ${className}`}>
|
||||
<div>
|
||||
<span className="font-medium">{label}</span>
|
||||
{helpText && <p className="text-xs text-stone-500 leading-relaxed mt-1">{helpText}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
helpText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CheckboxField: React.FC<CheckboxFieldProps> = ({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
helpText,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 ${className}`}>
|
||||
<label className="flex items-center gap-3 text-sm text-stone-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-5 h-5 rounded border-2 border-stone-300 bg-white 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)}
|
||||
/>
|
||||
<span className="font-medium">{label}</span>
|
||||
</label>
|
||||
{helpText && <p className="text-xs text-stone-500 ml-7 leading-relaxed">{helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputGroup;
|
||||
export { Field, CheckboxField };
|
||||
@@ -1,90 +0,0 @@
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface SectionCardProps {
|
||||
title: string;
|
||||
priority: 'critical' | 'infrastructure' | 'development' | 'tools';
|
||||
icon: React.ReactElement;
|
||||
children: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
hasChanges?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const priorityStyles = {
|
||||
critical: 'bg-gradient-to-br from-primary-50 to-white border-primary-200',
|
||||
infrastructure: 'bg-gradient-to-br from-stone-50 to-white border-stone-200',
|
||||
development: 'bg-gradient-to-br from-amber-50 to-white border-amber-200',
|
||||
tools: 'bg-stone-50 border-stone-200',
|
||||
} as const;
|
||||
|
||||
const priorityIconColors = {
|
||||
critical: 'text-primary-500',
|
||||
infrastructure: 'text-stone-500',
|
||||
development: 'text-amber-500',
|
||||
tools: 'text-stone-400',
|
||||
} as const;
|
||||
|
||||
const SectionCard: React.FC<SectionCardProps> = ({
|
||||
title,
|
||||
priority,
|
||||
icon,
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultExpanded = true,
|
||||
hasChanges = false,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (collapsible) {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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-stone-100' : ''}`}
|
||||
onClick={handleToggle}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex-shrink-0 ${priorityIconColors[priority]} ${loading ? 'relative' : ''}`}>
|
||||
{loading ? (
|
||||
<div className="h-5 w-5 border-2 border-stone-200 border-t-stone-600 rounded-full animate-spin" />
|
||||
) : (
|
||||
React.cloneElement(icon as React.ReactElement<{ className?: string }>, {
|
||||
className: 'h-5 w-5',
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold text-stone-900 font-display">{title}</h3>
|
||||
{hasChanges && <div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
|
||||
{loading && <span className="text-sm text-stone-500 ml-2">Loading...</span>}
|
||||
</div>
|
||||
</div>
|
||||
{collapsible && (
|
||||
<div className="text-stone-500 transition-transform duration-200">
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(!collapsible || isExpanded) && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="space-y-8">{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionCard;
|
||||
@@ -1,187 +0,0 @@
|
||||
import { CheckCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
import React from 'react';
|
||||
|
||||
interface ValidatedFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
type?: 'text' | 'password' | 'url' | 'number';
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
validation?: 'valid' | 'invalid' | 'none';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ValidatedField: React.FC<ValidatedFieldProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
required = false,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
helpText,
|
||||
className = '',
|
||||
fullWidth = false,
|
||||
validation = 'none',
|
||||
disabled = false,
|
||||
}) => {
|
||||
const hasError = !!error;
|
||||
const isValid = validation === 'valid' && !hasError && value.trim() !== '';
|
||||
|
||||
const inputClasses = `
|
||||
w-full px-4 py-3 rounded-lg border transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${
|
||||
hasError
|
||||
? 'border-coral-500/60 bg-coral-50 text-coral-700 placeholder-coral-400/50 focus:border-coral-500 focus:ring-coral-500/30'
|
||||
: isValid
|
||||
? 'border-sage-500/60 bg-sage-50 text-stone-900 placeholder-stone-400 focus:border-sage-500 focus:ring-sage-500/30'
|
||||
: 'border-stone-200 bg-white text-stone-900 placeholder-stone-400 focus:border-primary-500/50 focus:ring-primary-500/30'
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`space-y-3 text-sm text-stone-600 ${fullWidth ? 'md:col-span-2' : ''} ${className}`}>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{label}
|
||||
{required && <span className="text-coral-400 ml-1">*</span>}
|
||||
</span>
|
||||
{helpText && <p className="text-xs text-stone-500 leading-relaxed mt-1">{helpText}</p>}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type}
|
||||
className={inputClasses}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Validation icon */}
|
||||
{(hasError || isValid) && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
{hasError ? (
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-coral-400" />
|
||||
) : isValid ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-sage-400" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && (
|
||||
<div className="flex items-center gap-2 text-xs text-coral-600">
|
||||
<ExclamationTriangleIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
interface ValidatedSelectProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string; description?: string }>;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
helpText?: string;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
disabled?: boolean;
|
||||
validation?: 'valid' | 'invalid' | 'none';
|
||||
}
|
||||
|
||||
const ValidatedSelect: React.FC<ValidatedSelectProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
error,
|
||||
required = false,
|
||||
placeholder = 'Select option...',
|
||||
helpText,
|
||||
className = '',
|
||||
fullWidth = false,
|
||||
disabled = false,
|
||||
validation = 'none',
|
||||
}) => {
|
||||
const hasError = !!error;
|
||||
const isValid = validation === 'valid' && !hasError && value.trim() !== '';
|
||||
|
||||
const selectClasses = `
|
||||
w-full px-4 py-3 rounded-lg border transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${
|
||||
hasError
|
||||
? 'border-coral-500/60 bg-coral-50 text-coral-700 focus:border-coral-500 focus:ring-coral-500/30'
|
||||
: isValid
|
||||
? 'border-sage-500/60 bg-sage-50 text-stone-900 focus:border-sage-500 focus:ring-sage-500/30'
|
||||
: 'border-stone-200 bg-white text-stone-900 focus:border-primary-500/50 focus:ring-primary-500/30'
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={`space-y-3 text-sm text-stone-600 ${fullWidth ? 'md:col-span-2' : ''} ${className}`}>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{label}
|
||||
{required && <span className="text-coral-400 ml-1">*</span>}
|
||||
</span>
|
||||
{helpText && <p className="text-xs text-stone-500 leading-relaxed mt-1">{helpText}</p>}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
className={selectClasses}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={disabled}>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Validation icon */}
|
||||
{(hasError || isValid) && (
|
||||
<div className="absolute inset-y-0 right-8 flex items-center pr-3 pointer-events-none">
|
||||
{hasError ? (
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-coral-400" />
|
||||
) : isValid ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-sage-400" />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{hasError && (
|
||||
<div className="flex items-center gap-2 text-xs text-coral-600">
|
||||
<ExclamationTriangleIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidatedField;
|
||||
export { ValidatedSelect };
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Country } from '../types/onboarding';
|
||||
|
||||
export const countries: Country[] = [
|
||||
{ code: 'US', name: 'United States', flag: '🇺🇸', dialCode: '+1' },
|
||||
{ code: 'CA', name: 'Canada', flag: '🇨🇦', dialCode: '+1' },
|
||||
{ code: 'GB', name: 'United Kingdom', flag: '🇬🇧', dialCode: '+44' },
|
||||
{ code: 'DE', name: 'Germany', flag: '🇩🇪', dialCode: '+49' },
|
||||
{ code: 'FR', name: 'France', flag: '🇫🇷', dialCode: '+33' },
|
||||
{ code: 'JP', name: 'Japan', flag: '🇯🇵', dialCode: '+81' },
|
||||
{ code: 'KR', name: 'South Korea', flag: '🇰🇷', dialCode: '+82' },
|
||||
{ code: 'CN', name: 'China', flag: '🇨🇳', dialCode: '+86' },
|
||||
{ code: 'IN', name: 'India', flag: '🇮🇳', dialCode: '+91' },
|
||||
{ code: 'AU', name: 'Australia', flag: '🇦🇺', dialCode: '+61' },
|
||||
{ code: 'BR', name: 'Brazil', flag: '🇧🇷', dialCode: '+55' },
|
||||
{ code: 'MX', name: 'Mexico', flag: '🇲🇽', dialCode: '+52' },
|
||||
{ code: 'AR', name: 'Argentina', flag: '🇦🇷', dialCode: '+54' },
|
||||
{ code: 'CL', name: 'Chile', flag: '🇨🇱', dialCode: '+56' },
|
||||
{ code: 'SG', name: 'Singapore', flag: '🇸🇬', dialCode: '+65' },
|
||||
{ code: 'HK', name: 'Hong Kong', flag: '🇭🇰', dialCode: '+852' },
|
||||
{ code: 'CH', name: 'Switzerland', flag: '🇨🇭', dialCode: '+41' },
|
||||
{ code: 'NL', name: 'Netherlands', flag: '🇳🇱', dialCode: '+31' },
|
||||
{ code: 'SE', name: 'Sweden', flag: '🇸🇪', dialCode: '+46' },
|
||||
{ code: 'NO', name: 'Norway', flag: '🇳🇴', dialCode: '+47' },
|
||||
];
|
||||
@@ -1,8 +1,15 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { ConnectedTool } from '../services/intelligenceApi';
|
||||
import type { ActionableItemStatus, ChatMessage } from '../types/intelligence';
|
||||
|
||||
interface ConnectedTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
skillId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-only implementations of Intelligence action hooks.
|
||||
* Items come from the local conscious memory layer — actions are applied in-memory.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* useLocalModelStatus — returns true when a local AI model is ready and serving.
|
||||
*
|
||||
* Polls openhumanLocalAiStatus() every POLL_INTERVAL_MS.
|
||||
* Returns false in non-Tauri environments (browser dev / remote-only).
|
||||
* This is the gate used by the multi-bubble delivery path.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { isTauri, openhumanLocalAiStatus } from '../utils/tauriCommands';
|
||||
|
||||
const POLL_INTERVAL_MS = 12_000;
|
||||
|
||||
export function useLocalModelStatus(): boolean {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
if (!isTauri()) return;
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await openhumanLocalAiStatus();
|
||||
if (mountedRef.current) {
|
||||
setIsActive(res.result?.state === 'ready');
|
||||
}
|
||||
} catch {
|
||||
if (mountedRef.current) setIsActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
void check();
|
||||
const id = setInterval(() => void check(), POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isActive;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { Socket } from 'socket.io-client';
|
||||
|
||||
import { socketService } from '../services/socketService';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
import { selectSocketStatus } from '../store/socketSelectors';
|
||||
|
||||
/**
|
||||
* React hook for using the Socket.IO connection
|
||||
* Note: The socket connection is managed by SocketProvider based on JWT token.
|
||||
* This hook provides access to the socket instance and methods.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { socket, isConnected, emit, on, off } = useSocket();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* on('ready', () => {
|
||||
* console.log('Socket ready!');
|
||||
* });
|
||||
*
|
||||
* return () => {
|
||||
* off('ready');
|
||||
* };
|
||||
* }, [on, off]);
|
||||
* ```
|
||||
*/
|
||||
export const useSocket = () => {
|
||||
const listenersRef = useRef<Array<{ event: string; callback: (...args: unknown[]) => void }>>([]);
|
||||
const socketStatus = useAppSelector(selectSocketStatus);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup: remove all listeners registered through this hook
|
||||
listenersRef.current.forEach(({ event, callback }) => {
|
||||
socketService.off(event, callback);
|
||||
});
|
||||
listenersRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
const emit = (event: string, data?: unknown) => {
|
||||
socketService.emit(event, data);
|
||||
};
|
||||
|
||||
const on = (event: string, callback: (...args: unknown[]) => void) => {
|
||||
socketService.on(event, callback);
|
||||
listenersRef.current.push({ event, callback });
|
||||
};
|
||||
|
||||
const off = (event: string, callback?: (...args: unknown[]) => void) => {
|
||||
socketService.off(event, callback);
|
||||
if (callback) {
|
||||
listenersRef.current = listenersRef.current.filter(
|
||||
listener => listener.event !== event || listener.callback !== callback
|
||||
);
|
||||
} else {
|
||||
listenersRef.current = listenersRef.current.filter(listener => listener.event !== event);
|
||||
}
|
||||
};
|
||||
|
||||
const once = (event: string, callback: (...args: unknown[]) => void) => {
|
||||
socketService.once(event, callback);
|
||||
};
|
||||
|
||||
return {
|
||||
socket: socketService.getSocket() as Socket | null,
|
||||
isConnected: socketStatus === 'connected',
|
||||
status: socketStatus,
|
||||
emit,
|
||||
on,
|
||||
off,
|
||||
once,
|
||||
};
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* React hook to listen for tools updates and trigger re-renders
|
||||
*
|
||||
* Components using this hook will automatically re-render when TOOLS.md
|
||||
* is updated and the cache is refreshed.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { forceToolsCacheRefresh } from '../lib/tools/file-watcher';
|
||||
|
||||
interface ToolsUpdateEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to listen for tools updates
|
||||
* @returns timestamp of last tools update (triggers re-renders)
|
||||
*/
|
||||
export function useToolsUpdates(): number {
|
||||
const [lastUpdate, setLastUpdate] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleToolsUpdate = (event: CustomEvent<ToolsUpdateEvent>) => {
|
||||
console.log('🔔 Tools update detected, triggering component re-render');
|
||||
setLastUpdate(event.detail.timestamp);
|
||||
};
|
||||
|
||||
// Listen for tools-updated events
|
||||
window.addEventListener('tools-updated', handleToolsUpdate as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('tools-updated', handleToolsUpdate as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return lastUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a callback that forces tools refresh
|
||||
* @returns function to manually trigger tools refresh
|
||||
*/
|
||||
export function useForceToolsRefresh(): () => Promise<void> {
|
||||
const forceRefresh = async () => {
|
||||
return forceToolsCacheRefresh();
|
||||
};
|
||||
|
||||
return forceRefresh;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Tool Bridge — converts skill tools to AI tool registry format.
|
||||
*
|
||||
* Namespaces tool names as `<skillId>__<toolName>` to avoid collisions.
|
||||
* Each tool's execute function delegates to skillManager.callTool().
|
||||
*/
|
||||
|
||||
import { skillManager } from "./manager";
|
||||
import type { SkillToolDefinition } from "./types";
|
||||
|
||||
export interface BridgedTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
execute: (args: Record<string, unknown>) => Promise<string>;
|
||||
skillId: string;
|
||||
originalName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a skill's tools into bridged tools with namespaced names.
|
||||
*/
|
||||
export function bridgeSkillTools(
|
||||
skillId: string,
|
||||
tools: SkillToolDefinition[],
|
||||
): BridgedTool[] {
|
||||
return tools.map((tool) => ({
|
||||
name: `${skillId}__${tool.name}`,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
skillId,
|
||||
originalName: tool.name,
|
||||
execute: async (args: Record<string, unknown>): Promise<string> => {
|
||||
const result = await skillManager.callTool(skillId, tool.name, args);
|
||||
if (result.isError) {
|
||||
throw new Error(
|
||||
result.content.map((c) => c.text).join("\n") || "Tool execution failed",
|
||||
);
|
||||
}
|
||||
return result.content.map((c) => c.text).join("\n");
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the skill ID and original tool name from a namespaced name.
|
||||
*/
|
||||
export function parseToolName(namespacedName: string): {
|
||||
skillId: string;
|
||||
toolName: string;
|
||||
} | null {
|
||||
const idx = namespacedName.indexOf("__");
|
||||
if (idx === -1) return null;
|
||||
return {
|
||||
skillId: namespacedName.substring(0, idx),
|
||||
toolName: namespacedName.substring(idx + 2),
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Legacy UI-side tools cache watcher removed.
|
||||
* Tool/config refresh now comes from core RPC and socket events.
|
||||
*/
|
||||
export function startToolsFileWatcher(): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
export function stopToolsFileWatcher(): void {
|
||||
// no-op
|
||||
}
|
||||
|
||||
export async function forceToolsCacheRefresh(): Promise<void> {
|
||||
// Preserve compatibility for callers that expect a resolved promise.
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { BACKEND_URL } from '../../utils/config';
|
||||
|
||||
const DEFAULT_BACKEND_URL = 'https://api.tinyhumans.ai';
|
||||
|
||||
function normalizedBackendUrl(baseUrl?: string): string {
|
||||
const value = (baseUrl || BACKEND_URL || DEFAULT_BACKEND_URL).trim();
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function buildWebhookIngressUrl(tunnelUuid: string, baseUrl?: string): string {
|
||||
return `${normalizedBackendUrl(baseUrl)}/webhooks/ingress/${encodeURIComponent(tunnelUuid)}`;
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* HTTP JSON-RPC to the desktop core sidecar shared by the main app and overlay.
|
||||
* Mirrors the naming normalization in app/src/services/coreRpcClient.ts (subset).
|
||||
*/
|
||||
|
||||
let nextJsonRpcId = 1;
|
||||
|
||||
export const normalizeLegacyMethod = (method: string): string => {
|
||||
if (method.startsWith('openhuman.accessibility_')) {
|
||||
return method.replace('openhuman.accessibility_', 'openhuman.screen_intelligence_');
|
||||
}
|
||||
return method;
|
||||
};
|
||||
|
||||
/** RpcOutcome with non-empty logs serializes as `{ result, logs }` in the core. */
|
||||
const unwrapCliCompatibleJson = <T>(raw: unknown): T => {
|
||||
if (
|
||||
raw !== null &&
|
||||
typeof raw === 'object' &&
|
||||
Object.prototype.hasOwnProperty.call(raw, 'result') &&
|
||||
Object.prototype.hasOwnProperty.call(raw, 'logs')
|
||||
) {
|
||||
const keys = Object.keys(raw);
|
||||
const { logs } = raw as { logs: unknown };
|
||||
if (keys.length === 2 && Array.isArray(logs)) {
|
||||
return (raw as { result: T }).result;
|
||||
}
|
||||
}
|
||||
return raw as T;
|
||||
};
|
||||
|
||||
interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse<T> {
|
||||
jsonrpc?: string;
|
||||
id?: number | string | null;
|
||||
result?: T;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
const DEFAULT_RPC_TIMEOUT_MS = 10_000;
|
||||
|
||||
const isJsonRpcEnvelope = (value: unknown): value is JsonRpcResponse<unknown> => {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(value, 'error') ||
|
||||
Object.prototype.hasOwnProperty.call(value, 'result') ||
|
||||
Object.prototype.hasOwnProperty.call(value, 'id') ||
|
||||
Object.prototype.hasOwnProperty.call(value, 'jsonrpc')
|
||||
);
|
||||
};
|
||||
|
||||
export const callParentCoreRpc = async <T>(
|
||||
rpcUrl: string,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs: number = DEFAULT_RPC_TIMEOUT_MS
|
||||
): Promise<T> => {
|
||||
const normalizedMethod = normalizeLegacyMethod(method);
|
||||
const payload = {
|
||||
jsonrpc: '2.0' as const,
|
||||
id: nextJsonRpcId++,
|
||||
method: normalizedMethod,
|
||||
params,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(rpcUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (
|
||||
(err instanceof DOMException && err.name === 'AbortError') ||
|
||||
(err instanceof Error && err.name === 'AbortError')
|
||||
) {
|
||||
throw new Error(
|
||||
`Core RPC request timed out after ${timeoutMs}ms (method: ${normalizedMethod})`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Core RPC HTTP ${response.status}: ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (!isJsonRpcEnvelope(json)) {
|
||||
throw new Error('Invalid JSON-RPC envelope');
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
throw new Error(json.error.message || 'Core RPC returned an error');
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(json, 'result')) {
|
||||
throw new Error('Core RPC response missing result');
|
||||
}
|
||||
|
||||
return unwrapCliCompatibleJson<T>(json.result);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isTauri, openhumanUpdateAnalyticsSettings } from '../../../utils/tauriCommands';
|
||||
|
||||
interface AnalyticsStepProps {
|
||||
onNext: (analyticsEnabled: boolean) => void;
|
||||
}
|
||||
|
||||
const AnalyticsStep = ({ onNext }: AnalyticsStepProps) => {
|
||||
const [selectedOption, setSelectedOption] = useState('shareAnalytics');
|
||||
|
||||
const handleContinue = () => {
|
||||
const enabled = selectedOption === 'shareAnalytics';
|
||||
|
||||
// Sync to core config so the Rust process also respects the setting
|
||||
if (isTauri()) {
|
||||
openhumanUpdateAnalyticsSettings({ enabled }).catch(() => {
|
||||
/* best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
onNext(enabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2 text-stone-900">Anonymized Analytics</h1>
|
||||
<p className="text-stone-600 text-sm">
|
||||
We collect fully anonymized usage data to help improve the app. No personal data,
|
||||
messages, or wallet keys are ever collected. You can change this anytime in Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-4">
|
||||
<div
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
selectedOption === 'shareAnalytics'
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-stone-200 bg-white hover:border-stone-300'
|
||||
}`}
|
||||
onClick={() => setSelectedOption('shareAnalytics')}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex items-center justify-center mt-0.5">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedOption === 'shareAnalytics'
|
||||
? 'border-primary-500 bg-primary-500'
|
||||
: 'border-stone-300 bg-white'
|
||||
}`}>
|
||||
{selectedOption === 'shareAnalytics' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1 text-sm text-stone-900">
|
||||
Share Anonymized Usage Data
|
||||
</h3>
|
||||
<p className="text-stone-600 text-xs leading-relaxed">
|
||||
Share anonymized crash reports and usage analytics to help us improve features and
|
||||
performance. All data is fully anonymized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
selectedOption === 'maximumPrivacy'
|
||||
? 'border-primary-500 bg-primary-50'
|
||||
: 'border-stone-200 bg-white hover:border-stone-300'
|
||||
}`}
|
||||
onClick={() => setSelectedOption('maximumPrivacy')}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex items-center justify-center mt-0.5">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedOption === 'maximumPrivacy'
|
||||
? 'border-primary-500 bg-primary-500'
|
||||
: 'border-stone-300 bg-white'
|
||||
}`}>
|
||||
{selectedOption === 'maximumPrivacy' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-1 text-sm text-stone-900">Don't Collect Anything</h3>
|
||||
<p className="text-stone-600 text-xs leading-relaxed">
|
||||
We won't collect any usage analytics or crash reports. Keep all your data completely
|
||||
private.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200 mb-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<svg className="w-5 h-5 text-sage-500 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-sm text-stone-900">
|
||||
You can change this setting anytime
|
||||
</p>
|
||||
<p className="text-stone-600 text-xs mt-1">
|
||||
Your privacy preferences can be updated in Settings > Privacy & Security
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl">
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsStep;
|
||||
@@ -1,176 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import BinanceIcon from '../../../assets/icons/binance.svg';
|
||||
import GoogleIcon from '../../../assets/icons/GoogleIcon';
|
||||
import MetamaskIcon from '../../../assets/icons/metamask.svg';
|
||||
import NotionIcon from '../../../assets/icons/notion.svg';
|
||||
import SkillSetupModal from '../../../components/skills/SkillSetupModal';
|
||||
import type { SkillConnectionStatus } from '../../../lib/skills/types';
|
||||
|
||||
interface ConnectStepProps {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
interface ConnectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactElement;
|
||||
comingSoon?: boolean;
|
||||
skillId?: string;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<SkillConnectionStatus, { label: string; classes: string }> = {
|
||||
connected: { label: 'Connected', classes: 'bg-sage-500/20 text-sage-400' },
|
||||
connecting: { label: 'Connecting...', classes: 'bg-amber-500/20 text-amber-400' },
|
||||
not_authenticated: { label: 'Not Authenticated', classes: 'bg-amber-500/20 text-amber-400' },
|
||||
disconnected: { label: 'Disconnected', classes: 'bg-stone-500/20 text-stone-400' },
|
||||
error: { label: 'Error', classes: 'bg-coral-500/20 text-coral-400' },
|
||||
offline: { label: 'Offline', classes: 'bg-stone-500/20 text-stone-400' },
|
||||
setup_required: { label: 'Connect', classes: 'bg-primary-500/20 text-primary-400' },
|
||||
};
|
||||
|
||||
function ConnectOptionRow({
|
||||
option,
|
||||
onConnect,
|
||||
}: {
|
||||
option: ConnectOption;
|
||||
onConnect: (option: ConnectOption) => void;
|
||||
}) {
|
||||
const connectionStatus = 'setup_required' as SkillConnectionStatus;
|
||||
const disabled = option.comingSoon;
|
||||
|
||||
let badge: React.ReactElement;
|
||||
if (option.comingSoon) {
|
||||
badge = (
|
||||
<span className="text-xs text-stone-500 bg-stone-100 px-2 py-0.5 rounded">Coming Soon</span>
|
||||
);
|
||||
} else if (option.skillId) {
|
||||
const cfg = STATUS_BADGE[connectionStatus];
|
||||
badge = <span className={`text-xs px-2 py-0.5 rounded ${cfg.classes}`}>{cfg.label}</span>;
|
||||
} else {
|
||||
badge = (
|
||||
<span className="text-xs bg-primary-500/20 text-primary-400 px-2 py-0.5 rounded">
|
||||
Connect
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={() => onConnect(option)}
|
||||
className={`w-full flex items-start space-x-3 p-3 bg-white border border-stone-200 rounded-xl transition-all duration-200 text-left ${
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-stone-300 hover:shadow-medium'
|
||||
}`}>
|
||||
<div className="flex-shrink-0 mt-0.5">{option.icon}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm text-stone-900">{option.name}</span>
|
||||
{badge}
|
||||
</div>
|
||||
<p className="text-stone-600 text-xs mt-1">{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const ConnectStep = ({ onNext }: ConnectStepProps) => {
|
||||
const [setupModalOpen, setSetupModalOpen] = useState(false);
|
||||
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
|
||||
const [activeSkillName, setActiveSkillName] = useState<string>('');
|
||||
const [activeSkillDescription, setActiveSkillDescription] = useState<string>('');
|
||||
|
||||
const connectOptions: ConnectOption[] = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
description: 'Manage emails, contacts and calendar events',
|
||||
icon: <GoogleIcon />,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'notion',
|
||||
name: 'Notion',
|
||||
description: 'Manage tasks, documents and everything else in your Notion',
|
||||
icon: <img src={NotionIcon} alt="Notion" className="w-5 h-5" />,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'wallet',
|
||||
name: 'Web3 Wallet',
|
||||
description: 'Trade the trenches in a safe and secure way.',
|
||||
icon: <img src={MetamaskIcon} alt="Metamask" className="w-5 h-5" />,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'exchange',
|
||||
name: 'Crypto Trading Exchanges',
|
||||
description: 'Connect and make trades with deep insights.',
|
||||
icon: <img src={BinanceIcon} alt="Binance" className="w-5 h-5" />,
|
||||
comingSoon: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleConnect = (option: ConnectOption) => {
|
||||
if (option.comingSoon) return;
|
||||
if (option.skillId) {
|
||||
setActiveSkillId(option.skillId);
|
||||
setActiveSkillName(option.name);
|
||||
setActiveSkillDescription(option.description);
|
||||
setSetupModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2 text-stone-900">Connect Accounts</h1>
|
||||
<p className="text-stone-600 text-sm">
|
||||
The more accounts you connect, the more powerful the intelligence will be.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
{connectOptions.map(option => (
|
||||
<ConnectOptionRow key={option.id} option={option} onConnect={handleConnect} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-stone-50 rounded-xl border border-stone-200">
|
||||
<div className="flex items-start space-x-2">
|
||||
<div>
|
||||
<p className="font-medium text-sm text-stone-900">
|
||||
Remember everything is private & encrypted!
|
||||
</p>
|
||||
<p className="text-stone-600 text-xs mt-1">
|
||||
All data and credentials are stored locally and follows a strict zero-data retention
|
||||
policy so you won't have to worry about anything getting leaked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl mt-4">
|
||||
Continue
|
||||
</button>
|
||||
|
||||
{/* Setup modal */}
|
||||
{setupModalOpen && activeSkillId && (
|
||||
<SkillSetupModal
|
||||
skillId={activeSkillId}
|
||||
skillName={activeSkillName}
|
||||
skillDescription={activeSkillDescription}
|
||||
onClose={() => {
|
||||
setSetupModalOpen(false);
|
||||
setActiveSkillId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectStep;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { inviteApi } from '../../../services/api/inviteApi';
|
||||
|
||||
interface InviteCodeStepProps {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const InviteCodeStep = ({ onNext }: InviteCodeStepProps) => {
|
||||
const [code, setCode] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleRedeem = async () => {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await inviteApi.redeemInviteCode(trimmed);
|
||||
setSuccess(true);
|
||||
setTimeout(() => onNext(), 1500);
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String((err as { error: string }).error)
|
||||
: 'Invalid or expired invite code';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-bold mb-2 text-stone-900">Have an Invite Code?</h1>
|
||||
<p className="text-stone-600 text-sm">
|
||||
Enter an invite code from a friend to unlock free credits. You can also skip this step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-12 h-12 bg-sage-50 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<svg
|
||||
className="w-6 h-6 text-sage-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sage-500 font-medium text-sm">Invite code redeemed successfully!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value.toUpperCase())}
|
||||
onKeyDown={e => e.key === 'Enter' && handleRedeem()}
|
||||
placeholder="Enter invite code"
|
||||
className="w-full px-4 py-3 bg-stone-50 border border-stone-200 rounded-xl text-center font-mono text-lg tracking-widest text-stone-900 placeholder:text-stone-400 placeholder:tracking-normal placeholder:font-sans placeholder:text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{error && <p className="text-coral-500 text-xs mt-2 text-center">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={handleRedeem}
|
||||
disabled={isLoading || !code.trim()}
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{isLoading ? 'Redeeming...' : 'Redeem Code'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={isLoading}
|
||||
className="w-full py-2.5 text-sm font-medium rounded-xl text-stone-400 hover:text-stone-700 transition-colors">
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteCodeStep;
|
||||
@@ -1,337 +0,0 @@
|
||||
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { skillManager } from '../../../lib/skills/manager';
|
||||
import { useCoreState } from '../../../providers/CoreStateProvider';
|
||||
import {
|
||||
deriveAesKeyFromMnemonic,
|
||||
deriveEvmAddressFromMnemonic,
|
||||
generateMnemonicPhrase,
|
||||
MNEMONIC_GENERATE_WORD_COUNT,
|
||||
validateMnemonicPhrase,
|
||||
} from '../../../utils/cryptoKeys';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
|
||||
const BIP39_IMPORT_LENGTHS = [12, 15, 18, 21, 24] as const;
|
||||
|
||||
const IMPORT_SLOTS_INITIAL = MNEMONIC_GENERATE_WORD_COUNT;
|
||||
|
||||
interface MnemonicStepProps {
|
||||
onNext: () => void | Promise<void>;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const MnemonicStep = ({ onNext, onBack: _onBack }: MnemonicStepProps) => {
|
||||
const { snapshot, setEncryptionKey } = useCoreState();
|
||||
const user = snapshot.currentUser;
|
||||
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);
|
||||
|
||||
const mnemonic = useMemo(() => generateMnemonicPhrase(), []);
|
||||
const words = useMemo(() => mnemonic.split(' '), [mnemonic]);
|
||||
|
||||
const [importWords, setImportWords] = useState<string[]>(Array(IMPORT_SLOTS_INITIAL).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]);
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmed(false);
|
||||
setError(null);
|
||||
setImportValid(null);
|
||||
setImportWords(Array(IMPORT_SLOTS_INITIAL).fill(''));
|
||||
}, [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) => {
|
||||
const pastedWords = value.trim().split(/\s+/).filter(Boolean);
|
||||
if (pastedWords.length > 1) {
|
||||
const fullPhraseLen = pastedWords.length;
|
||||
if (BIP39_IMPORT_LENGTHS.includes(fullPhraseLen as (typeof BIP39_IMPORT_LENGTHS)[number])) {
|
||||
setImportWords(pastedWords.map(w => w.toLowerCase()));
|
||||
setImportValid(null);
|
||||
inputRefs.current[fullPhraseLen - 1]?.focus();
|
||||
return;
|
||||
}
|
||||
const newWords = [...importWords];
|
||||
const slotCount = newWords.length;
|
||||
for (let i = 0; i < Math.min(pastedWords.length, slotCount - index); i++) {
|
||||
newWords[index + i] = pastedWords[i].toLowerCase();
|
||||
}
|
||||
setImportWords(newWords);
|
||||
setImportValid(null);
|
||||
const nextEmpty = newWords.findIndex(w => !w);
|
||||
const focusIndex = nextEmpty === -1 ? slotCount - 1 : nextEmpty;
|
||||
inputRefs.current[focusIndex]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const newWords = [...importWords];
|
||||
newWords[index] = value.toLowerCase().trim();
|
||||
setImportWords(newWords);
|
||||
setImportValid(null);
|
||||
|
||||
if (value.trim() && index < newWords.length - 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());
|
||||
const n = filledWords.length;
|
||||
|
||||
if (!BIP39_IMPORT_LENGTHS.includes(n as (typeof BIP39_IMPORT_LENGTHS)[number])) {
|
||||
setError(`Recovery phrase must be ${BIP39_IMPORT_LENGTHS.join(', ')} words (you have ${n}).`);
|
||||
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) {
|
||||
setError('User not loaded. Please sign in again or refresh the page.');
|
||||
return;
|
||||
}
|
||||
await setEncryptionKey(aesKey);
|
||||
await skillManager.setWalletAddress(walletAddress);
|
||||
await onNext();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Something went wrong. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const importWordCount = importWords.filter(w => w.trim()).length;
|
||||
const isImportComplete =
|
||||
importWords.every(w => w.trim()) &&
|
||||
BIP39_IMPORT_LENGTHS.includes(importWordCount as (typeof BIP39_IMPORT_LENGTHS)[number]);
|
||||
const canContinue = mode === 'generate' ? confirmed : isImportComplete;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
|
||||
{mode === 'generate' ? (
|
||||
<>
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-xl font-bold mb-2 text-stone-900">Lastly, your Recovery Phrase</h1>
|
||||
<p className="text-stone-600 text-sm">
|
||||
Write down these {MNEMONIC_GENERATE_WORD_COUNT} words in order and store them
|
||||
somewhere safe. This phrase encrypts your data and can never be recovered if lost. You
|
||||
can always back up later.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-50 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 border border-stone-200 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-900">{word}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full flex items-center justify-center gap-2 border border-stone-200 hover:border-stone-400 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-500">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>
|
||||
|
||||
<button
|
||||
onClick={() => setMode('import')}
|
||||
className="w-full text-center text-sm text-primary-600 hover:text-primary-500 transition-colors mb-3">
|
||||
I already have a recovery phrase
|
||||
</button>
|
||||
|
||||
<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-700">
|
||||
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 text-stone-900">Import Recovery Phrase</h1>
|
||||
<p className="text-stone-600 text-sm">
|
||||
Enter your recovery phrase below, or paste the full phrase into any field (12 words
|
||||
for new backups; 24-word phrases from older versions still work).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-50 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 outline-none transition-colors text-stone-900 ${
|
||||
importValid === false && word.trim()
|
||||
? 'border-coral-400 focus:border-coral-300'
|
||||
: importValid === true
|
||||
? 'border-sage-400 focus:border-sage-300'
|
||||
: 'border-stone-300 focus:border-primary-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importValid === true && (
|
||||
<div className="flex items-center gap-2 text-sage-500 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>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setMode('generate')}
|
||||
className="w-full text-center text-sm text-primary-600 hover:text-primary-500 transition-colors mb-3">
|
||||
Generate a new recovery phrase instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-coral-400 text-sm mb-3 text-center">{error}</p>}
|
||||
|
||||
<OnboardingNextButton
|
||||
label="Finish Setup"
|
||||
onClick={handleContinue}
|
||||
disabled={!canContinue}
|
||||
loading={loading}
|
||||
loadingLabel="Securing Your Data..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MnemonicStep;
|
||||
@@ -1,101 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
getDefaultEnabledTools,
|
||||
getToolsByCategory,
|
||||
TOOL_CATEGORIES,
|
||||
type ToolCategory,
|
||||
} from '../../../utils/toolDefinitions';
|
||||
import OnboardingNextButton from '../components/OnboardingNextButton';
|
||||
|
||||
interface ToolsStepProps {
|
||||
onNext: (enabledTools: string[]) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_DESCRIPTIONS: Record<ToolCategory, string> = {
|
||||
System: 'Shell access and version control',
|
||||
Files: 'Read and write files on disk',
|
||||
Vision: 'Screen capture and image analysis',
|
||||
Web: 'Browser, HTTP, and web search',
|
||||
Memory: 'Persistent recall for the AI',
|
||||
Automation: 'Cron jobs and scheduled tasks',
|
||||
};
|
||||
|
||||
const ToolsStep = ({ onNext, onBack: _onBack }: ToolsStepProps) => {
|
||||
const toolsByCategory = getToolsByCategory();
|
||||
const [enabled, setEnabled] = useState<Record<string, boolean>>(() => {
|
||||
const defaults: Record<string, boolean> = {};
|
||||
const defaultEnabled = getDefaultEnabledTools();
|
||||
for (const cat of TOOL_CATEGORIES) {
|
||||
for (const tool of toolsByCategory[cat]) {
|
||||
defaults[tool.id] = defaultEnabled.includes(tool.id);
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
});
|
||||
|
||||
const toggle = (toolId: string) => {
|
||||
setEnabled(prev => ({ ...prev, [toolId]: !prev[toolId] }));
|
||||
};
|
||||
|
||||
const enabledList = Object.entries(enabled)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => k);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-stone-200 bg-white p-8 shadow-soft animate-fade-up">
|
||||
<div className="text-center mb-5">
|
||||
<h1 className="text-xl font-bold mb-2 text-stone-900">Enable Tools</h1>
|
||||
<p className="text-stone-600 text-sm">
|
||||
Choose which capabilities OpenHuman can use on your behalf.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-5 max-h-[380px] overflow-y-auto pr-1">
|
||||
{TOOL_CATEGORIES.map(category => {
|
||||
const tools = toolsByCategory[category];
|
||||
if (tools.length === 0) return null;
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="mb-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-stone-500">
|
||||
{category}
|
||||
</h2>
|
||||
<p className="text-xs text-stone-400">{CATEGORY_DESCRIPTIONS[category]}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{tools.map(tool => (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
onClick={() => toggle(tool.id)}
|
||||
className="w-full flex items-center justify-between p-2.5 rounded-xl border border-stone-200 bg-white hover:border-stone-300 transition-colors text-left">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-stone-900">{tool.displayName}</span>
|
||||
<p className="text-xs text-stone-500 mt-0.5">{tool.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-3 flex-shrink-0 w-9 h-5 rounded-full transition-colors relative ${
|
||||
enabled[tool.id] ? 'bg-sage-500' : 'bg-stone-200'
|
||||
}`}>
|
||||
<div
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||
enabled[tool.id] ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<OnboardingNextButton onClick={() => onNext(enabledList)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolsStep;
|
||||
@@ -1,363 +0,0 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
|
||||
|
||||
import { AgentToolRegistry } from '../agentToolRegistry';
|
||||
|
||||
// Mock Tauri invoke
|
||||
vi.mock('@tauri-apps/api/core');
|
||||
|
||||
describe('AgentToolRegistry', () => {
|
||||
let service: AgentToolRegistry;
|
||||
const mockInvoke = invoke as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
service = AgentToolRegistry.getInstance();
|
||||
vi.clearAllMocks();
|
||||
service.clearCache(); // Clear cache between tests
|
||||
});
|
||||
|
||||
describe('loadToolSchemas', () => {
|
||||
test('should load tool schemas from Tauri using OpenClaw format', async () => {
|
||||
const mockSchemas = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'github_list_issues',
|
||||
description: 'List GitHub issues for a repository',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
owner: { type: 'string', description: 'Repository owner' },
|
||||
repo: { type: 'string', description: 'Repository name' },
|
||||
},
|
||||
required: ['owner', 'repo'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'notion_create_page',
|
||||
description: 'Create a new Notion page',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Page title' },
|
||||
content: { type: 'string', description: 'Page content' },
|
||||
},
|
||||
required: ['title'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockInvoke.mockResolvedValue(mockSchemas);
|
||||
|
||||
const schemas = await service.loadToolSchemas();
|
||||
|
||||
expect(schemas).toHaveLength(2);
|
||||
expect(schemas[0].function.name).toBe('github_list_issues');
|
||||
expect(schemas[1].function.name).toBe('notion_create_page');
|
||||
expect(mockInvoke).toHaveBeenCalledWith('runtime_get_tool_schemas');
|
||||
});
|
||||
|
||||
test('should cache tool schemas to avoid repeated calls', async () => {
|
||||
const mockSchemas = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'Test tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockInvoke.mockResolvedValue(mockSchemas);
|
||||
|
||||
// First call
|
||||
const schemas1 = await service.loadToolSchemas();
|
||||
// Second call
|
||||
const schemas2 = await service.loadToolSchemas();
|
||||
|
||||
expect(schemas1).toEqual(schemas2);
|
||||
// Should only invoke Tauri once due to caching (TTL = 5 minutes)
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should force reload when requested', async () => {
|
||||
const mockSchemas = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'Test tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockInvoke.mockResolvedValue(mockSchemas);
|
||||
|
||||
// First call
|
||||
await service.loadToolSchemas();
|
||||
// Force reload
|
||||
await service.loadToolSchemas(true);
|
||||
|
||||
// Should invoke Tauri twice
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('should handle empty tool schema response', async () => {
|
||||
mockInvoke.mockResolvedValue([]);
|
||||
|
||||
const schemas = await service.loadToolSchemas();
|
||||
|
||||
expect(schemas).toHaveLength(0);
|
||||
expect(mockInvoke).toHaveBeenCalledWith('runtime_get_tool_schemas');
|
||||
});
|
||||
|
||||
test('should return empty schemas when skill runtime invoke fails', async () => {
|
||||
const errorMessage = 'Failed to load tool schemas';
|
||||
mockInvoke.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const schemas = await service.loadToolSchemas();
|
||||
|
||||
expect(schemas).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
test('should execute tool using OpenClaw format with success', async () => {
|
||||
const mockResult = {
|
||||
success: true,
|
||||
output: '{"issues": [{"title": "Bug fix", "number": 1}]}',
|
||||
error: null,
|
||||
execution_time: 1500,
|
||||
};
|
||||
|
||||
mockInvoke.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.executeTool(
|
||||
'github',
|
||||
'list_issues',
|
||||
'{"owner":"user","repo":"test"}'
|
||||
);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.result).toBe(mockResult.output);
|
||||
expect(result.executionTimeMs).toBe(1500);
|
||||
expect(result.toolName).toBe('list_issues');
|
||||
expect(result.skillId).toBe('github');
|
||||
|
||||
// Verify correct tool_id format and arguments
|
||||
expect(mockInvoke).toHaveBeenCalledWith('runtime_execute_tool', {
|
||||
toolId: 'github_list_issues',
|
||||
args: '{"owner":"user","repo":"test"}',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle tool execution failure', async () => {
|
||||
const mockResult = {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'Tool not found: invalid_tool',
|
||||
execution_time: 100,
|
||||
};
|
||||
|
||||
mockInvoke.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await service.executeTool('invalid', 'tool', '{}');
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.errorMessage).toBe('Tool not found: invalid_tool');
|
||||
expect(result.result).toBe('Tool not found: invalid_tool');
|
||||
expect(result.executionTimeMs).toBe(100);
|
||||
});
|
||||
|
||||
test('should handle tool execution without execution_time', async () => {
|
||||
const mockResult = {
|
||||
success: true,
|
||||
output: 'Success',
|
||||
error: null,
|
||||
// No execution_time provided
|
||||
};
|
||||
|
||||
mockInvoke.mockResolvedValue(mockResult);
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = await service.executeTool('test', 'tool', '{}');
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
|
||||
expect(result.executionTimeMs).toBeLessThanOrEqual(endTime - startTime + 10); // Allow small margin
|
||||
});
|
||||
|
||||
test('should handle Tauri invoke exception', async () => {
|
||||
const errorMessage = 'Network error';
|
||||
mockInvoke.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const result = await service.executeTool('test', 'tool', '{}');
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.errorMessage).toBe(errorMessage);
|
||||
expect(result.result).toBe(errorMessage);
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('should generate unique execution IDs', async () => {
|
||||
const mockResult = { success: true, output: 'test', error: null, execution_time: 100 };
|
||||
|
||||
mockInvoke.mockResolvedValue(mockResult);
|
||||
|
||||
const result1 = await service.executeTool('test', 'tool1', '{}');
|
||||
const result2 = await service.executeTool('test', 'tool2', '{}');
|
||||
|
||||
expect(result1.id).not.toBe(result2.id);
|
||||
expect(result1.id).toMatch(/^exec_\d+_[a-z0-9]+$/);
|
||||
expect(result2.id).toMatch(/^exec_\d+_[a-z0-9]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool management methods', () => {
|
||||
beforeEach(async () => {
|
||||
const mockSchemas = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'github_list_issues',
|
||||
description: 'List GitHub issues',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'github_create_issue',
|
||||
description: 'Create GitHub issue',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'notion_create_page',
|
||||
description: 'Create Notion page',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockInvoke.mockResolvedValue(mockSchemas);
|
||||
await service.loadToolSchemas();
|
||||
});
|
||||
|
||||
test('getToolByName should find tool by name', () => {
|
||||
const tool = service.getToolByName('github_list_issues');
|
||||
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.function.name).toBe('github_list_issues');
|
||||
expect(tool?.function.description).toBe('List GitHub issues');
|
||||
});
|
||||
|
||||
test('getToolByName should return undefined for non-existent tool', () => {
|
||||
const tool = service.getToolByName('non_existent_tool');
|
||||
|
||||
expect(tool).toBeUndefined();
|
||||
});
|
||||
|
||||
test('getAllTools should return all loaded tools', () => {
|
||||
const tools = service.getAllTools();
|
||||
|
||||
expect(tools).toHaveLength(3);
|
||||
expect(tools.map(t => t.function.name)).toEqual([
|
||||
'github_list_issues',
|
||||
'github_create_issue',
|
||||
'notion_create_page',
|
||||
]);
|
||||
});
|
||||
|
||||
test('getToolsBySkill should organize tools by skill ID', () => {
|
||||
const toolsBySkill = service.getToolsBySkill();
|
||||
|
||||
// Skill ID is the substring before the last underscore in tool function names
|
||||
expect(toolsBySkill).toHaveProperty('github_list');
|
||||
expect(toolsBySkill).toHaveProperty('github_create');
|
||||
expect(toolsBySkill).toHaveProperty('notion_create');
|
||||
expect(toolsBySkill.github_list).toHaveLength(1);
|
||||
expect(toolsBySkill.github_create).toHaveLength(1);
|
||||
expect(toolsBySkill.notion_create).toHaveLength(1);
|
||||
|
||||
expect(toolsBySkill.github_list[0].function.name).toBe('github_list_issues');
|
||||
expect(toolsBySkill.github_create[0].function.name).toBe('github_create_issue');
|
||||
expect(toolsBySkill.notion_create[0].function.name).toBe('notion_create_page');
|
||||
});
|
||||
|
||||
test('getToolStats should return accurate statistics', () => {
|
||||
const stats = service.getToolStats();
|
||||
|
||||
expect(stats.totalTools).toBe(3);
|
||||
expect(stats.skillCount).toBe(3);
|
||||
expect(stats.categories).toHaveProperty('GitHub', 2);
|
||||
expect(stats.categories).toHaveProperty('Notion', 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper methods', () => {
|
||||
test('extractSkillIdFromToolName should parse skill ID correctly', () => {
|
||||
// Use reflection to access private method
|
||||
const extractMethod = (service as any).extractSkillIdFromToolName.bind(service);
|
||||
|
||||
// Uses lastIndexOf('_'): skill id is everything before the final underscore
|
||||
expect(extractMethod('github_list_issues')).toBe('github_list');
|
||||
expect(extractMethod('notion_create_page')).toBe('notion_create');
|
||||
expect(extractMethod('complex_skill_name_tool_name')).toBe('complex_skill_name_tool');
|
||||
expect(extractMethod('invalid_format')).toBe('invalid');
|
||||
expect(extractMethod('simpletool')).toBeNull();
|
||||
});
|
||||
|
||||
test('extractCategoryFromSkillId should categorize skills correctly', () => {
|
||||
// Use reflection to access private method
|
||||
const extractMethod = (service as any).extractCategoryFromSkillId.bind(service);
|
||||
|
||||
expect(extractMethod('github')).toBe('GitHub');
|
||||
expect(extractMethod('github_enterprise')).toBe('GitHub');
|
||||
expect(extractMethod('notion')).toBe('Notion');
|
||||
expect(extractMethod('telegram')).toBe('Telegram');
|
||||
expect(extractMethod('gmail')).toBe('Email');
|
||||
expect(extractMethod('calendar')).toBe('Calendar');
|
||||
expect(extractMethod('slack')).toBe('Slack');
|
||||
expect(extractMethod('crypto_wallet')).toBe('Crypto');
|
||||
expect(extractMethod('unknown_skill')).toBe('Other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCache', () => {
|
||||
test('should clear cached tool schemas', async () => {
|
||||
const mockSchemas = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'Test tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockInvoke.mockResolvedValue(mockSchemas);
|
||||
|
||||
// Load schemas
|
||||
await service.loadToolSchemas();
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clear cache
|
||||
service.clearCache();
|
||||
|
||||
// Load again - should call Tauri again
|
||||
await service.loadToolSchemas();
|
||||
expect(mockInvoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* Agent Tool Registry Service
|
||||
*
|
||||
* Unified tool discovery and execution using the consolidated systems:
|
||||
* - telegram_get_tools: Get Telegram tools in OpenAI-compatible format
|
||||
* - telegram_execute_tool: Execute Telegram tools with enhanced validation
|
||||
* - Fallback to skill system for non-Telegram tools (temporary)
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import type { AgentToolExecution, AgentToolSchema, IAgentToolRegistry } from '../types/agent';
|
||||
|
||||
// OpenClaw format types from Rust
|
||||
interface OpenClawToolSchema {
|
||||
type: string;
|
||||
function: { name: string; description: string; parameters: Record<string, unknown> };
|
||||
}
|
||||
|
||||
interface OpenClawToolResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
execution_time?: number;
|
||||
}
|
||||
|
||||
export class AgentToolRegistry implements IAgentToolRegistry {
|
||||
private static instance: AgentToolRegistry;
|
||||
private toolSchemas: AgentToolSchema[] = [];
|
||||
private lastLoadTime = 0;
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
static getInstance(): AgentToolRegistry {
|
||||
if (!this.instance) {
|
||||
this.instance = new AgentToolRegistry();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tool schemas from unified systems (Telegram + skill system fallback)
|
||||
*/
|
||||
async loadToolSchemas(forceReload = false): Promise<AgentToolSchema[]> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached tools if still fresh
|
||||
if (!forceReload && this.toolSchemas.length > 0 && now - this.lastLoadTime < this.CACHE_TTL) {
|
||||
return this.toolSchemas;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔧 Loading tool schemas from unified systems...');
|
||||
|
||||
const allTools: AgentToolSchema[] = [];
|
||||
|
||||
// Note: Telegram tools removed - no longer available
|
||||
console.log('🔧 Telegram tools not available (unified system removed)');
|
||||
|
||||
// 2. Load other tools from skill system (fallback for non-Telegram)
|
||||
try {
|
||||
console.log('🔧 Loading non-Telegram tools from skill system...');
|
||||
const skillTools = await invoke<OpenClawToolSchema[]>('runtime_get_tool_schemas');
|
||||
|
||||
// Filter out telegram tools to avoid duplicates
|
||||
const nonTelegramTools = skillTools.filter(
|
||||
tool =>
|
||||
!tool.function.name.includes('telegram') &&
|
||||
!tool.function.name.includes('tg') &&
|
||||
!this.extractCategoryFromSkillId(
|
||||
this.extractSkillIdFromToolName(tool.function.name) || ''
|
||||
).includes('Telegram')
|
||||
);
|
||||
|
||||
const skillSchemas = nonTelegramTools.map(tool => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters as AgentToolSchema['function']['parameters'],
|
||||
},
|
||||
}));
|
||||
|
||||
allTools.push(...skillSchemas);
|
||||
console.log(`✅ Loaded ${skillSchemas.length} non-Telegram tools from skill system`);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to load tools from skill system:', error);
|
||||
}
|
||||
|
||||
this.toolSchemas = allTools;
|
||||
this.lastLoadTime = now;
|
||||
|
||||
console.log(`✅ Tool registry updated: ${this.toolSchemas.length} total tools available`);
|
||||
|
||||
return this.toolSchemas;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load tool schemas:', error);
|
||||
throw new Error(`Failed to load tool schemas: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool using unified systems (Telegram unified or skill system fallback)
|
||||
*/
|
||||
async executeTool(
|
||||
skillId: string,
|
||||
toolName: string,
|
||||
toolArguments: string
|
||||
): Promise<AgentToolExecution> {
|
||||
const startTime = Date.now();
|
||||
const executionId = `exec_${startTime}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const execution: AgentToolExecution = {
|
||||
id: executionId,
|
||||
toolName,
|
||||
skillId,
|
||||
arguments: toolArguments,
|
||||
status: 'running',
|
||||
startTime,
|
||||
};
|
||||
|
||||
console.log(`🚀 [TOOL EXECUTION START] Executing tool: ${toolName} (skillId: ${skillId})`);
|
||||
console.log(`📝 [ARGUMENTS] Raw arguments:`, {
|
||||
arguments: toolArguments,
|
||||
type: typeof toolArguments,
|
||||
length: toolArguments?.length,
|
||||
isString: typeof toolArguments === 'string',
|
||||
parsed: (() => {
|
||||
try {
|
||||
return typeof toolArguments === 'string' ? JSON.parse(toolArguments) : toolArguments;
|
||||
} catch (e) {
|
||||
return 'Failed to parse: ' + e;
|
||||
}
|
||||
})(),
|
||||
});
|
||||
|
||||
try {
|
||||
// Determine if this is a Telegram tool
|
||||
const isTelegramTool =
|
||||
skillId.includes('telegram') ||
|
||||
skillId.includes('tg') ||
|
||||
toolName.includes('telegram') ||
|
||||
toolName.includes('tg') ||
|
||||
this.extractCategoryFromSkillId(skillId).includes('Telegram');
|
||||
|
||||
let result: OpenClawToolResult;
|
||||
|
||||
if (isTelegramTool) {
|
||||
// Telegram tools no longer available
|
||||
console.log(`🔧 [TELEGRAM TOOL] Tool "${toolName}" not available (unified system removed)`);
|
||||
result = {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'Telegram tools are no longer available (unified system removed)',
|
||||
};
|
||||
} else {
|
||||
// Use skill system for non-Telegram tools
|
||||
const toolId = `${skillId}_${toolName}`;
|
||||
console.log(`🔧 [BEFORE INVOKE] Calling runtime_execute_tool with:`);
|
||||
console.log(` toolId: "${toolId}"`);
|
||||
console.log(` args: ${toolArguments}`);
|
||||
console.log(` args type: ${typeof toolArguments}`);
|
||||
|
||||
result = await invoke<OpenClawToolResult>('runtime_execute_tool', {
|
||||
toolId,
|
||||
args: toolArguments,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔧 [AFTER INVOKE] Tool execution result:`, result);
|
||||
|
||||
execution.endTime = Date.now();
|
||||
// Use execution time from Rust if available, otherwise calculate locally
|
||||
execution.executionTimeMs = result.execution_time || execution.endTime - execution.startTime;
|
||||
|
||||
if (!result.success) {
|
||||
execution.status = 'error';
|
||||
execution.errorMessage = result.error || 'Unknown error occurred';
|
||||
execution.result = execution.errorMessage;
|
||||
|
||||
console.log(`❌ Tool execution failed: ${toolName} (${execution.executionTimeMs}ms)`);
|
||||
console.log(`❌ Error:`, execution.errorMessage);
|
||||
} else {
|
||||
execution.status = 'success';
|
||||
execution.result = result.output;
|
||||
|
||||
console.log(`✅ Tool execution completed: ${toolName} (${execution.executionTimeMs}ms)`);
|
||||
}
|
||||
|
||||
return execution;
|
||||
} catch (error) {
|
||||
execution.endTime = Date.now();
|
||||
execution.executionTimeMs = execution.endTime - execution.startTime;
|
||||
execution.status = 'error';
|
||||
execution.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
execution.result = execution.errorMessage;
|
||||
|
||||
console.error(`❌ Tool execution error: ${toolName}`, error);
|
||||
|
||||
return execution;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific tool by name
|
||||
*/
|
||||
getToolByName(toolName: string): AgentToolSchema | undefined {
|
||||
return this.toolSchemas.find(tool => tool.function.name === toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available tools
|
||||
*/
|
||||
getAllTools(): AgentToolSchema[] {
|
||||
return [...this.toolSchemas];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools organized by skill
|
||||
*/
|
||||
getToolsBySkill(): Record<string, AgentToolSchema[]> {
|
||||
const toolsBySkill: Record<string, AgentToolSchema[]> = {};
|
||||
|
||||
for (const tool of this.toolSchemas) {
|
||||
// Extract skill ID from tool name (format: skillId_toolName)
|
||||
const skillId = this.extractSkillIdFromToolName(tool.function.name) || 'unknown';
|
||||
|
||||
if (!toolsBySkill[skillId]) {
|
||||
toolsBySkill[skillId] = [];
|
||||
}
|
||||
toolsBySkill[skillId].push(tool);
|
||||
}
|
||||
|
||||
return toolsBySkill;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool execution statistics
|
||||
*/
|
||||
getToolStats(): { totalTools: number; skillCount: number; categories: Record<string, number> } {
|
||||
const categories: Record<string, number> = {};
|
||||
const skills = new Set<string>();
|
||||
|
||||
for (const tool of this.toolSchemas) {
|
||||
const skillId = this.extractSkillIdFromToolName(tool.function.name) || 'unknown';
|
||||
skills.add(skillId);
|
||||
|
||||
// Categorize by skill name
|
||||
const category = this.extractCategoryFromSkillId(skillId);
|
||||
categories[category] = (categories[category] || 0) + 1;
|
||||
}
|
||||
|
||||
return { totalTools: this.toolSchemas.length, skillCount: skills.size, categories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the tool registry cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.toolSchemas = [];
|
||||
this.lastLoadTime = 0;
|
||||
console.log('🔧 Tool registry cache cleared');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Private Helper Methods
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Extract skill ID from tool name (format: skillId_toolName)
|
||||
*/
|
||||
private extractSkillIdFromToolName(toolName: string): string | null {
|
||||
const underscoreIndex = toolName.lastIndexOf('_');
|
||||
if (underscoreIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
return toolName.substring(0, underscoreIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract category name from skill ID for organization
|
||||
*/
|
||||
private extractCategoryFromSkillId(skillId: string): string {
|
||||
// Common skill naming patterns
|
||||
if (skillId.includes('github') || skillId.includes('git')) return 'GitHub';
|
||||
if (skillId.includes('notion')) return 'Notion';
|
||||
if (skillId.includes('telegram') || skillId.includes('tg')) return 'Telegram';
|
||||
if (skillId.includes('email') || skillId.includes('gmail')) return 'Email';
|
||||
if (skillId.includes('calendar')) return 'Calendar';
|
||||
if (skillId.includes('slack')) return 'Slack';
|
||||
if (skillId.includes('discord')) return 'Discord';
|
||||
if (skillId.includes('twitter') || skillId.includes('x')) return 'Social';
|
||||
if (skillId.includes('file') || skillId.includes('fs')) return 'File System';
|
||||
if (skillId.includes('crypto') || skillId.includes('blockchain')) return 'Crypto';
|
||||
if (skillId.includes('ai') || skillId.includes('ml')) return 'AI/ML';
|
||||
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { managedDmApi } from '../managedDmApi';
|
||||
|
||||
vi.mock('../../apiClient', () => ({ apiClient: { post: vi.fn(), get: vi.fn() } }));
|
||||
|
||||
const apiClient = vi.mocked((await import('../../apiClient')).apiClient);
|
||||
|
||||
describe('managedDmApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initiates managed dm through the backend api', async () => {
|
||||
apiClient.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
token: 'dm-token',
|
||||
deepLink: 'https://t.me/openhuman_bot?start=manageddm_dm-token',
|
||||
expiresAt: '2026-04-04T12:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(managedDmApi.initiateManagedDm()).resolves.toEqual({
|
||||
token: 'dm-token',
|
||||
deepLink: 'https://t.me/openhuman_bot?start=manageddm_dm-token',
|
||||
expiresAt: '2026-04-04T12:00:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('polls until verified and returns the verified status', async () => {
|
||||
apiClient.get
|
||||
.mockResolvedValueOnce({ data: { verified: false, telegramUsername: null } })
|
||||
.mockResolvedValueOnce({ data: { verified: true, telegramUsername: 'telegram-user' } });
|
||||
|
||||
await expect(
|
||||
managedDmApi.pollManagedDmStatusUntilVerified('dm-token', { intervalMs: 0, timeoutMs: 100 })
|
||||
).resolves.toEqual({ verified: true, telegramUsername: 'telegram-user' });
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import type { ApiResponse } from '../../types/api';
|
||||
import { apiClient } from '../apiClient';
|
||||
|
||||
interface ActionableItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'pending' | 'dismissed' | 'snoozed' | 'completed';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// Add other fields based on backend schema
|
||||
}
|
||||
|
||||
interface ExecutionSession {
|
||||
id: string;
|
||||
itemId: string;
|
||||
status: 'running' | 'pending' | 'completed' | 'failed';
|
||||
// Add other fields based on backend schema
|
||||
}
|
||||
|
||||
interface ThreadData {
|
||||
threadId: string;
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actionable Items API endpoints
|
||||
*/
|
||||
export const actionableItemsApi = {
|
||||
/**
|
||||
* List actionable items for the authenticated user
|
||||
* GET /telegram/actionable-items
|
||||
*/
|
||||
getActionableItems: async (): Promise<ActionableItem[]> => {
|
||||
const response = await apiClient.get<ApiResponse<ActionableItem[]>>(
|
||||
'/telegram/actionable-items'
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an actionable item (dismiss, snooze, etc.)
|
||||
* PATCH /telegram/actionable-items/:itemId
|
||||
*/
|
||||
updateActionableItem: async (
|
||||
itemId: string,
|
||||
updates: { status?: ActionableItem['status']; snoozeUntil?: string }
|
||||
): Promise<ActionableItem> => {
|
||||
const response = await apiClient.patch<ApiResponse<ActionableItem>>(
|
||||
`/telegram/actionable-items/${itemId}`,
|
||||
updates
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get or create conversation thread for an actionable item
|
||||
* GET /telegram/actionable-items/:itemId/thread
|
||||
*/
|
||||
getItemThread: async (itemId: string): Promise<ThreadData> => {
|
||||
const response = await apiClient.get<ApiResponse<ThreadData>>(
|
||||
`/telegram/actionable-items/${itemId}/thread`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current execution session for an actionable item
|
||||
* GET /telegram/actionable-items/:itemId/session
|
||||
*/
|
||||
getItemSession: async (itemId: string): Promise<ExecutionSession> => {
|
||||
const response = await apiClient.get<ApiResponse<ExecutionSession>>(
|
||||
`/telegram/actionable-items/${itemId}/session`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start execution of an actionable item
|
||||
* POST /telegram/actionable-items/:itemId/execute
|
||||
*/
|
||||
executeItem: async (itemId: string): Promise<ExecutionSession> => {
|
||||
const response = await apiClient.post<ApiResponse<ExecutionSession>>(
|
||||
`/telegram/actionable-items/${itemId}/execute`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get execution session status
|
||||
* GET /telegram/execution-sessions/:sessionId
|
||||
*/
|
||||
getExecutionSession: async (sessionId: string): Promise<ExecutionSession> => {
|
||||
const response = await apiClient.get<ApiResponse<ExecutionSession>>(
|
||||
`/telegram/execution-sessions/${sessionId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm or reject pending execution step
|
||||
* POST /telegram/execution-sessions/:sessionId/confirm
|
||||
*/
|
||||
confirmExecutionStep: async (
|
||||
sessionId: string,
|
||||
action: 'confirm' | 'reject',
|
||||
data?: unknown
|
||||
): Promise<ExecutionSession> => {
|
||||
const response = await apiClient.post<ApiResponse<ExecutionSession>>(
|
||||
`/telegram/execution-sessions/${sessionId}/confirm`,
|
||||
{ action, data }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { ApiResponse } from '../../types/api';
|
||||
import { apiClient } from '../apiClient';
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPreview: string; // First few characters of the key
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
// Add other fields based on backend schema
|
||||
}
|
||||
|
||||
interface CreateApiKeyData {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CreateApiKeyResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string; // Full key only returned on creation
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Keys management endpoints
|
||||
*/
|
||||
export const apiKeysApi = {
|
||||
/**
|
||||
* Create API key
|
||||
* POST /api-keys
|
||||
*/
|
||||
createApiKey: async (data: CreateApiKeyData): Promise<CreateApiKeyResponse> => {
|
||||
const response = await apiClient.post<ApiResponse<CreateApiKeyResponse>>('/api-keys', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* List API keys
|
||||
* GET /api-keys
|
||||
*/
|
||||
getApiKeys: async (): Promise<ApiKey[]> => {
|
||||
const response = await apiClient.get<ApiResponse<ApiKey[]>>('/api-keys');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoke API key
|
||||
* DELETE /api-keys/:keyId
|
||||
*/
|
||||
revokeApiKey: async (keyId: string): Promise<void> => {
|
||||
await apiClient.delete<ApiResponse<unknown>>(`/api-keys/${keyId}`);
|
||||
},
|
||||
};
|
||||
@@ -1,54 +1,5 @@
|
||||
import { base64ToBytes, encryptIntegrationTokens } from '../../utils/integrationTokensCrypto';
|
||||
import { callCoreCommand } from '../coreCommandClient';
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
interface IntegrationTokensResponse {
|
||||
success: boolean;
|
||||
data?: { encrypted: string };
|
||||
}
|
||||
|
||||
interface IntegrationTokensPayload {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
type LinkableChannel = 'telegram' | 'discord';
|
||||
|
||||
interface RawChannelLinkTokenData {
|
||||
token?: string;
|
||||
linkToken?: string;
|
||||
jwtToken?: string;
|
||||
url?: string;
|
||||
linkUrl?: string;
|
||||
authUrl?: string;
|
||||
deepLinkUrl?: string;
|
||||
expiresAt?: string;
|
||||
expires_at?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ChannelLinkTokenResult {
|
||||
token: string;
|
||||
launchUrl?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map(byte => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function normalizeKeyToHex(rawKey: string): string {
|
||||
const trimmed = rawKey.trim();
|
||||
const maybeHex = trimmed.replace(/^0x/i, '');
|
||||
if (maybeHex.length === 64 && /^[0-9a-fA-F]+$/.test(maybeHex)) {
|
||||
return maybeHex.toLowerCase();
|
||||
}
|
||||
return bytesToHex(base64ToBytes(trimmed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a verified login token and return the JWT.
|
||||
* Works for both Telegram and OAuth login tokens.
|
||||
@@ -65,69 +16,3 @@ export async function consumeLoginToken(loginToken: string): Promise<string> {
|
||||
}
|
||||
return 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> {
|
||||
const response = await callCoreRpc<{ result: IntegrationTokensPayload }>({
|
||||
method: 'openhuman.auth.oauth_fetch_integration_tokens',
|
||||
params: { integrationId, key },
|
||||
});
|
||||
const tokens = response.result;
|
||||
if (!tokens?.accessToken || !tokens?.expiresAt) {
|
||||
throw new Error('Integration token handoff did not return required fields');
|
||||
}
|
||||
|
||||
const encrypted = await encryptIntegrationTokens(
|
||||
JSON.stringify({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken ?? '',
|
||||
expiresAt: tokens.expiresAt,
|
||||
}),
|
||||
normalizeKeyToHex(key)
|
||||
);
|
||||
return { success: true, data: { encrypted } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a short-lived link token that can be handed to a messaging channel login flow.
|
||||
* POST /auth/channels/:channel/link-token (auth required)
|
||||
*/
|
||||
export async function createChannelLinkToken(
|
||||
channel: LinkableChannel
|
||||
): Promise<ChannelLinkTokenResult> {
|
||||
const data = await callCoreCommand<RawChannelLinkTokenData>(
|
||||
'openhuman.auth_create_channel_link_token',
|
||||
{ channel }
|
||||
);
|
||||
const token =
|
||||
typeof data?.token === 'string'
|
||||
? data.token
|
||||
: typeof data?.linkToken === 'string'
|
||||
? data.linkToken
|
||||
: typeof data?.jwtToken === 'string'
|
||||
? data.jwtToken
|
||||
: '';
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Channel link token response missing token');
|
||||
}
|
||||
|
||||
const launchUrlCandidates = [data?.url, data?.linkUrl, data?.authUrl, data?.deepLinkUrl];
|
||||
const launchUrl = launchUrlCandidates.find(
|
||||
(value): value is string => typeof value === 'string' && value.trim().length > 0
|
||||
);
|
||||
const expiresAt =
|
||||
typeof data?.expiresAt === 'string'
|
||||
? data.expiresAt
|
||||
: typeof data?.expires_at === 'string'
|
||||
? data.expires_at
|
||||
: undefined;
|
||||
|
||||
return { token, launchUrl, expiresAt };
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { ApiResponse } from '../../types/api';
|
||||
import { apiClient } from '../apiClient';
|
||||
|
||||
interface FeedbackItem {
|
||||
id: string;
|
||||
type: 'bug' | 'feature_request' | 'general';
|
||||
title: string;
|
||||
description: string;
|
||||
steps?: string;
|
||||
status: 'open' | 'in_progress' | 'closed';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
// Add other fields based on backend schema
|
||||
}
|
||||
|
||||
interface CreateFeedbackData {
|
||||
type: FeedbackItem['type'];
|
||||
title: string;
|
||||
description: string;
|
||||
steps?: string;
|
||||
}
|
||||
|
||||
interface UpdateFeedbackData {
|
||||
title?: string;
|
||||
description?: string;
|
||||
steps?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feedback API endpoints
|
||||
*/
|
||||
export const feedbackApi = {
|
||||
/**
|
||||
* Submit feedback (bug, feature_request, general)
|
||||
* POST /feedback
|
||||
*/
|
||||
createFeedback: async (feedback: CreateFeedbackData): Promise<FeedbackItem> => {
|
||||
const response = await apiClient.post<ApiResponse<FeedbackItem>>('/feedback', feedback);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* List current user's feedback
|
||||
* GET /feedback
|
||||
*/
|
||||
getFeedback: async (): Promise<FeedbackItem[]> => {
|
||||
const response = await apiClient.get<ApiResponse<FeedbackItem[]>>('/feedback');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single feedback item
|
||||
* GET /feedback/:id
|
||||
*/
|
||||
getFeedbackById: async (id: string): Promise<FeedbackItem> => {
|
||||
const response = await apiClient.get<ApiResponse<FeedbackItem>>(`/feedback/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update feedback (description, steps, etc.)
|
||||
* PUT /feedback/:id
|
||||
*/
|
||||
updateFeedback: async (id: string, updates: UpdateFeedbackData): Promise<FeedbackItem> => {
|
||||
const response = await apiClient.put<ApiResponse<FeedbackItem>>(`/feedback/${id}`, updates);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
import { apiClient } from '../apiClient';
|
||||
|
||||
// ── Request types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type ChatRole = 'system' | 'user' | 'assistant' | 'tool';
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: { name: string; arguments: string };
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: ChatRole;
|
||||
content: string | null;
|
||||
tool_calls?: ToolCall[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
export interface ToolFunction {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Tool {
|
||||
type: 'function';
|
||||
function: ToolFunction;
|
||||
}
|
||||
|
||||
export interface ChatCompletionRequest {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: Tool[];
|
||||
tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
|
||||
openhuman?: { trace_tools?: boolean };
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
|
||||
export interface TextCompletionRequest {
|
||||
model: string;
|
||||
prompt: string;
|
||||
stream?: boolean;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
}
|
||||
|
||||
// ── Response types (OpenAI-compatible) ───────────────────────────────────────
|
||||
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
export interface ModelsListResponse {
|
||||
object: string;
|
||||
data: ModelInfo[];
|
||||
}
|
||||
|
||||
export interface ChatCompletionChoice {
|
||||
index: number;
|
||||
message: ChatMessage & { tool_calls?: ToolCall[] };
|
||||
finish_reason: string | null;
|
||||
}
|
||||
|
||||
export interface TextCompletionChoice {
|
||||
index: number;
|
||||
text: string;
|
||||
finish_reason: string | null;
|
||||
}
|
||||
|
||||
export interface CompletionUsage {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: ChatCompletionChoice[];
|
||||
usage: CompletionUsage;
|
||||
}
|
||||
|
||||
export interface TextCompletionResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: TextCompletionChoice[];
|
||||
usage: CompletionUsage;
|
||||
}
|
||||
|
||||
// ── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const inferenceApi = {
|
||||
/** GET /openai/v1/models — list available models */
|
||||
listModels: async (): Promise<ModelsListResponse> => {
|
||||
return apiClient.get<ModelsListResponse>('/openai/v1/models');
|
||||
},
|
||||
|
||||
/** POST /openai/v1/chat/completions — create a chat completion */
|
||||
createChatCompletion: async (body: ChatCompletionRequest): Promise<ChatCompletionResponse> => {
|
||||
return apiClient.post<ChatCompletionResponse>('/openai/v1/chat/completions', body);
|
||||
},
|
||||
|
||||
/** POST /openai/v1/completions — create a text completion */
|
||||
createCompletion: async (body: TextCompletionRequest): Promise<TextCompletionResponse> => {
|
||||
return apiClient.post<TextCompletionResponse>('/openai/v1/completions', body);
|
||||
},
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import { apiClient } from '../apiClient';
|
||||
|
||||
export interface ManagedDmInitiateResponse {
|
||||
token: string;
|
||||
deepLink: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface ManagedDmStatusResponse {
|
||||
verified: boolean;
|
||||
telegramUsername?: string | null;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
interface ApiEnvelope<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ManagedDmPollOptions {
|
||||
intervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 3_000;
|
||||
const DEFAULT_POLL_TIMEOUT_MS = 5 * 60 * 1_000;
|
||||
|
||||
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
|
||||
export async function initiateManagedDm(): Promise<ManagedDmInitiateResponse> {
|
||||
const response = await apiClient.post<ApiEnvelope<ManagedDmInitiateResponse>>(
|
||||
'/telegram/managed-dm/initiate'
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getManagedDmStatus(token: string): Promise<ManagedDmStatusResponse> {
|
||||
const response = await apiClient.get<ApiEnvelope<ManagedDmStatusResponse>>(
|
||||
`/telegram/managed-dm/status/${encodeURIComponent(token)}`
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function pollManagedDmStatusUntilVerified(
|
||||
token: string,
|
||||
options: ManagedDmPollOptions = {}
|
||||
): Promise<ManagedDmStatusResponse | null> {
|
||||
const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (options.signal?.aborted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getManagedDmStatus(token);
|
||||
if (status.verified) {
|
||||
return status;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort polling: keep trying until timeout or cancellation.
|
||||
}
|
||||
|
||||
await sleep(intervalMs, options.signal);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const managedDmApi = {
|
||||
initiateManagedDm,
|
||||
getManagedDmStatus,
|
||||
pollManagedDmStatusUntilVerified,
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { ApiResponse } from '../../types/api';
|
||||
import { apiClient } from '../apiClient';
|
||||
|
||||
interface UserSettings {
|
||||
// Add specific settings types based on backend schema
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings API endpoints
|
||||
*/
|
||||
export const settingsApi = {
|
||||
/**
|
||||
* Get user settings
|
||||
* GET /settings
|
||||
*/
|
||||
getSettings: async (): Promise<UserSettings> => {
|
||||
const response = await apiClient.get<ApiResponse<UserSettings>>('/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update user settings
|
||||
* PATCH /settings
|
||||
*/
|
||||
updateSettings: async (settings: Partial<UserSettings>): Promise<UserSettings> => {
|
||||
const response = await apiClient.patch<ApiResponse<UserSettings>>('/settings', settings);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set platforms connected
|
||||
* POST /settings/platforms-connected
|
||||
*/
|
||||
setPlatformsConnected: async (platforms: string[]): Promise<void> => {
|
||||
await apiClient.post<ApiResponse<unknown>>('/settings/platforms-connected', { platforms });
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* Daemon Health Service
|
||||
*
|
||||
* Manages health monitoring for the openhuman daemon by polling
|
||||
* `openhuman.health_snapshot` over core RPC from the frontend.
|
||||
* Polls the Rust core health snapshot and keeps the frontend daemon store in sync.
|
||||
*/
|
||||
import {
|
||||
type ComponentHealth,
|
||||
@@ -15,14 +14,10 @@ import { callCoreRpc } from './coreRpcClient';
|
||||
|
||||
export class DaemonHealthService {
|
||||
private healthTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly HEALTH_TIMEOUT_MS = 30000; // 30 seconds
|
||||
private readonly HEALTH_TIMEOUT_MS = 30000;
|
||||
private pollingIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly POLL_MS = 2000;
|
||||
|
||||
/**
|
||||
* Setup health event listener from the Rust daemon.
|
||||
* Should be called once when the app starts in Tauri mode.
|
||||
*/
|
||||
async setupHealthListener(): Promise<(() => void) | null> {
|
||||
if (this.pollingIntervalId) {
|
||||
return () => this.cleanup();
|
||||
@@ -37,7 +32,7 @@ export class DaemonHealthService {
|
||||
this.startHealthTimeout();
|
||||
}
|
||||
} catch {
|
||||
// Health endpoint can fail while sidecar is starting; timeout will mark disconnected.
|
||||
// The health endpoint can fail while the sidecar is starting.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,9 +45,6 @@ export class DaemonHealthService {
|
||||
return () => this.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup the health event listener.
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (this.pollingIntervalId) {
|
||||
clearInterval(this.pollingIntervalId);
|
||||
@@ -65,9 +57,6 @@ export class DaemonHealthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the health snapshot received from Rust.
|
||||
*/
|
||||
private parseHealthSnapshot(payload: unknown): HealthSnapshot | null {
|
||||
try {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
@@ -75,8 +64,6 @@ export class DaemonHealthService {
|
||||
}
|
||||
|
||||
const data = payload as Record<string, unknown>;
|
||||
|
||||
// Validate required fields
|
||||
if (
|
||||
typeof data.pid !== 'number' ||
|
||||
typeof data.updated_at !== 'string' ||
|
||||
@@ -87,7 +74,6 @@ export class DaemonHealthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse components
|
||||
const components: Record<string, ComponentHealth> = {};
|
||||
const componentsData = data.components as Record<string, unknown>;
|
||||
|
||||
@@ -105,13 +91,12 @@ export class DaemonHealthService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate status is a valid ComponentStatus
|
||||
if (comp.status !== 'ok' && comp.status !== 'error' && comp.status !== 'starting') {
|
||||
continue;
|
||||
}
|
||||
|
||||
components[name] = {
|
||||
status: comp.status as 'ok' | 'error' | 'starting',
|
||||
status: comp.status,
|
||||
updated_at: comp.updated_at,
|
||||
last_ok: typeof comp.last_ok === 'string' ? comp.last_ok : undefined,
|
||||
last_error: typeof comp.last_error === 'string' ? comp.last_error : undefined,
|
||||
@@ -120,9 +105,9 @@ export class DaemonHealthService {
|
||||
}
|
||||
|
||||
return {
|
||||
pid: data.pid as number,
|
||||
updated_at: data.updated_at as string,
|
||||
uptime_seconds: data.uptime_seconds as number,
|
||||
pid: data.pid,
|
||||
updated_at: data.updated_at,
|
||||
uptime_seconds: data.uptime_seconds,
|
||||
components,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -131,33 +116,20 @@ export class DaemonHealthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update daemon state based on received health snapshot.
|
||||
*/
|
||||
private updateDaemonStoreFromHealth(snapshot: HealthSnapshot): void {
|
||||
const userId = this.getUserId();
|
||||
|
||||
try {
|
||||
updateHealthSnapshot(userId, snapshot);
|
||||
updateHealthSnapshot(this.getUserId(), snapshot);
|
||||
} catch (error) {
|
||||
console.error('[DaemonHealth] Error updating daemon store from health:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or restart the health timeout.
|
||||
* If no health events are received within the timeout period,
|
||||
* the daemon status will be set to 'disconnected'.
|
||||
*/
|
||||
private startHealthTimeout(): void {
|
||||
// Clear existing timeout
|
||||
if (this.healthTimeoutId) {
|
||||
clearTimeout(this.healthTimeoutId);
|
||||
}
|
||||
|
||||
const userId = this.getUserId();
|
||||
|
||||
// Set new timeout
|
||||
this.healthTimeoutId = setTimeout(() => {
|
||||
console.warn('[DaemonHealth] Health timeout reached - setting status to disconnected');
|
||||
setDaemonStatus(userId, 'disconnected');
|
||||
@@ -165,19 +137,25 @@ export class DaemonHealthService {
|
||||
}, this.HEALTH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user ID for daemon state management.
|
||||
*/
|
||||
private getUserId(): string {
|
||||
const token = getCoreStateSnapshot().snapshot.sessionToken;
|
||||
if (!token) return '__pending__';
|
||||
if (!token) {
|
||||
return '__pending__';
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return '__pending__';
|
||||
if (parts.length !== 3) {
|
||||
return '__pending__';
|
||||
}
|
||||
|
||||
const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const payloadJson = atob(payloadBase64);
|
||||
const payload = JSON.parse(payloadJson);
|
||||
const payload = JSON.parse(payloadJson) as {
|
||||
sub?: string;
|
||||
tgUserId?: string;
|
||||
userId?: string;
|
||||
};
|
||||
return payload.tgUserId || payload.userId || payload.sub || '__pending__';
|
||||
} catch {
|
||||
return '__pending__';
|
||||
@@ -185,5 +163,4 @@ export class DaemonHealthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const daemonHealthService = new DaemonHealthService();
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { ActionableItemStatus } from '../types/intelligence';
|
||||
import { apiClient } from './apiClient';
|
||||
|
||||
/**
|
||||
* Backend API response types for Intelligence system
|
||||
*/
|
||||
export interface BackendActionableItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
source: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt?: string;
|
||||
snoozeUntil?: string;
|
||||
actionable: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
hasComplexAction?: boolean;
|
||||
dismissedAt?: string;
|
||||
completedAt?: string;
|
||||
reminderCount?: number;
|
||||
// Backend-specific fields
|
||||
threadId?: string;
|
||||
executionStatus?: 'idle' | 'running' | 'completed' | 'failed';
|
||||
currentSessionId?: string;
|
||||
}
|
||||
|
||||
export interface BackendChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
timestamp: string;
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export interface BackendThreadResponse {
|
||||
threadId: string;
|
||||
messages: BackendChatMessage[];
|
||||
}
|
||||
|
||||
export interface BackendExecutionResponse {
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
status: 'started' | 'running' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
export interface ConnectedTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
skillId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligence API Service - handles all REST API calls for the Intelligence system
|
||||
*/
|
||||
export class IntelligenceApiService {
|
||||
/**
|
||||
* Get all actionable items from the backend
|
||||
*/
|
||||
async getActionableItems(): Promise<BackendActionableItem[]> {
|
||||
try {
|
||||
const response = await apiClient.get<{ items: BackendActionableItem[] }>(
|
||||
'/telegram/actionable-items'
|
||||
);
|
||||
return response.items || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch actionable items:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of an actionable item
|
||||
*/
|
||||
async updateItemStatus(itemId: string, status: ActionableItemStatus): Promise<void> {
|
||||
try {
|
||||
await apiClient.patch(`/actionable-items/${itemId}`, { status });
|
||||
} catch (error) {
|
||||
console.error('Failed to update item status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snooze an actionable item until a specific time
|
||||
*/
|
||||
async snoozeItem(itemId: string, snoozeUntil: Date): Promise<void> {
|
||||
try {
|
||||
await apiClient.patch(`/actionable-items/${itemId}`, {
|
||||
status: 'snoozed',
|
||||
snoozeUntil: snoozeUntil.toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to snooze item:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a conversation thread for an actionable item
|
||||
*/
|
||||
async getOrCreateThread(itemId: string): Promise<BackendThreadResponse> {
|
||||
try {
|
||||
const response = await apiClient.get<BackendThreadResponse>(`/${itemId}/thread`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to get or create thread:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history for a specific thread
|
||||
*/
|
||||
async getChatHistory(threadId: string): Promise<BackendChatMessage[]> {
|
||||
try {
|
||||
const response = await apiClient.get<{ messages: BackendChatMessage[] }>(
|
||||
`/threads/${threadId}/messages`
|
||||
);
|
||||
return response.messages || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get chat history:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start task execution for an actionable item with connected tools
|
||||
*/
|
||||
async executeTask(
|
||||
itemId: string,
|
||||
connectedTools: ConnectedTool[]
|
||||
): Promise<BackendExecutionResponse> {
|
||||
try {
|
||||
const response = await apiClient.post<BackendExecutionResponse>(`/${itemId}/execute`, {
|
||||
connectedTools,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to execute task:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution status for a specific execution ID
|
||||
*/
|
||||
async getExecutionStatus(
|
||||
executionId: string
|
||||
): Promise<{
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
progress?: Array<Record<string, unknown>>;
|
||||
result?: unknown;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get(`/executions/${executionId}/status`);
|
||||
return response as {
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
progress?: Array<Record<string, unknown>>;
|
||||
result?: unknown;
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get execution status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running execution
|
||||
*/
|
||||
async cancelExecution(executionId: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.post(`/executions/${executionId}/cancel`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel execution:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const intelligenceApi = new IntelligenceApiService();
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Agent Tool Registry Types
|
||||
*
|
||||
* Minimal type definitions for agent tool registry functionality.
|
||||
* Based on OpenClaw compatibility requirements.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tool schema for AI provider registration (OpenAI function calling format)
|
||||
*/
|
||||
export interface AgentToolSchema {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: {
|
||||
type: 'object';
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool execution record
|
||||
*/
|
||||
export interface AgentToolExecution {
|
||||
id: string;
|
||||
toolName: string;
|
||||
skillId: string;
|
||||
arguments: string; // JSON string
|
||||
status: 'running' | 'success' | 'error';
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
executionTimeMs?: number;
|
||||
result?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent tool registry service interface
|
||||
*/
|
||||
export interface IAgentToolRegistry {
|
||||
/**
|
||||
* Load tool schemas from the skill system
|
||||
*/
|
||||
loadToolSchemas(forceReload?: boolean): Promise<AgentToolSchema[]>;
|
||||
|
||||
/**
|
||||
* Execute a tool using the skill system
|
||||
*/
|
||||
executeTool(
|
||||
skillId: string,
|
||||
toolName: string,
|
||||
toolArguments: string
|
||||
): Promise<AgentToolExecution>;
|
||||
|
||||
/**
|
||||
* Get a specific tool by name
|
||||
*/
|
||||
getToolByName(toolName: string): AgentToolSchema | undefined;
|
||||
|
||||
/**
|
||||
* Get all available tools
|
||||
*/
|
||||
getAllTools(): AgentToolSchema[];
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
/**
|
||||
* Intelligence Chat API Types
|
||||
*
|
||||
* TypeScript definitions for backend integration
|
||||
* These types ensure consistency between frontend and backend implementations
|
||||
*/
|
||||
|
||||
// ===== Core Types =====
|
||||
|
||||
export type ConversationFlow =
|
||||
| 'discovery'
|
||||
| 'planning'
|
||||
| 'confirmation'
|
||||
| 'execution'
|
||||
| 'completion'
|
||||
| 'auto_close';
|
||||
|
||||
export type MessageType =
|
||||
| 'message'
|
||||
| 'plan'
|
||||
| 'progress'
|
||||
| 'completion'
|
||||
| 'discovery_question'
|
||||
| 'confirmation_request';
|
||||
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
|
||||
export type SessionStatus = 'active' | 'executing' | 'completed' | 'failed';
|
||||
|
||||
export type WorkflowType =
|
||||
| 'meeting_prep'
|
||||
| 'email_response'
|
||||
| 'system_task'
|
||||
| 'document_analysis'
|
||||
| 'calendar_management';
|
||||
|
||||
// ===== Request/Response Types =====
|
||||
|
||||
export interface CreateChatSessionRequest {
|
||||
actionable_item_id: string;
|
||||
user_id: string;
|
||||
context: { item_type: WorkflowType; metadata: Record<string, unknown> };
|
||||
}
|
||||
|
||||
export interface CreateChatSessionResponse {
|
||||
success: true;
|
||||
data: {
|
||||
session_id: string;
|
||||
initial_message: {
|
||||
content: string;
|
||||
type: MessageType;
|
||||
options: string[];
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
expected_flow: ConversationFlow[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
sender: 'ai' | 'user';
|
||||
timestamp: string; // ISO 8601
|
||||
type: MessageType;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatSessionDetails {
|
||||
session_id: string;
|
||||
status: SessionStatus;
|
||||
current_flow: ConversationFlow;
|
||||
messages: ChatMessage[];
|
||||
context: Record<string, unknown>;
|
||||
execution_id?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
content: string;
|
||||
sender: 'user';
|
||||
current_flow: ConversationFlow;
|
||||
context: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
success: true;
|
||||
data: {
|
||||
ai_response: {
|
||||
content: string;
|
||||
type: MessageType;
|
||||
next_flow: ConversationFlow;
|
||||
options: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
should_execute: boolean;
|
||||
execution_plan?: ExecutionPlan;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Execution Types =====
|
||||
|
||||
export interface ExecutionStep {
|
||||
id: string;
|
||||
label: string;
|
||||
status: TaskStatus;
|
||||
estimated_duration: number;
|
||||
dependencies: string[];
|
||||
started_at?: string; // ISO 8601
|
||||
completed_at?: string; // ISO 8601
|
||||
progress_percentage?: number;
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExecutionPlan {
|
||||
id: string;
|
||||
steps: ExecutionStep[];
|
||||
estimated_total_duration: number;
|
||||
requirements: { gmail_access?: boolean; notion_access?: boolean; calendar_access?: boolean };
|
||||
}
|
||||
|
||||
export interface StartExecutionRequest {
|
||||
execution_plan_id: string;
|
||||
confirmed: true;
|
||||
modifications?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StartExecutionResponse {
|
||||
success: true;
|
||||
data: {
|
||||
execution_id: string;
|
||||
status: 'started';
|
||||
estimated_duration: number;
|
||||
steps: ExecutionStep[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionStatusResponse {
|
||||
success: true;
|
||||
data: {
|
||||
execution_id: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
current_step?: { id: string; label: string; status: TaskStatus; progress_percentage: number };
|
||||
completed_steps: string[];
|
||||
results?: ExecutionResults;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionResults {
|
||||
summary: string;
|
||||
artifacts: Artifact[];
|
||||
metrics: {
|
||||
total_duration: number;
|
||||
emails_processed?: number;
|
||||
documents_created?: number;
|
||||
apis_called?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
type: 'notion_doc' | 'email_draft' | 'calendar_event' | 'document' | 'link';
|
||||
title: string;
|
||||
url: string;
|
||||
created_at: string; // ISO 8601
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ===== WebSocket Event Types =====
|
||||
|
||||
export interface WebSocketMessage<T = unknown> {
|
||||
type: string;
|
||||
data: T;
|
||||
timestamp?: string; // ISO 8601
|
||||
}
|
||||
|
||||
// Tool definition for chat initialization
|
||||
export interface ChatTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] };
|
||||
}
|
||||
|
||||
// Client → Server Events
|
||||
export interface ChatInitEvent {
|
||||
type: 'chat:init';
|
||||
data: { tools: ChatTool[]; sessionId?: string; timestamp: number };
|
||||
}
|
||||
|
||||
export interface AuthenticateEvent {
|
||||
type: 'authenticate';
|
||||
data: { token: string; session_id: string };
|
||||
}
|
||||
|
||||
export interface JoinSessionEvent {
|
||||
type: 'join_session';
|
||||
data: { session_id: string };
|
||||
}
|
||||
|
||||
// Server → Client Events
|
||||
export interface AuthenticatedEvent {
|
||||
type: 'authenticated';
|
||||
data: { user_id: string; session_id: string };
|
||||
}
|
||||
|
||||
export interface ExecutionStepProgressEvent {
|
||||
type: 'execution_step_progress';
|
||||
data: {
|
||||
session_id: string;
|
||||
execution_id: string;
|
||||
step_id: string;
|
||||
status: TaskStatus;
|
||||
progress_percentage: number;
|
||||
message: string;
|
||||
timestamp: string; // ISO 8601
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionCompleteEvent {
|
||||
type: 'execution_complete';
|
||||
data: {
|
||||
session_id: string;
|
||||
execution_id: string;
|
||||
status: 'completed' | 'failed';
|
||||
results: ExecutionResults;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AIThinkingEvent {
|
||||
type: 'ai_thinking';
|
||||
data: { session_id: string; message: string; estimated_time: number };
|
||||
}
|
||||
|
||||
export interface ErrorEvent {
|
||||
type: 'error';
|
||||
data: {
|
||||
session_id: string;
|
||||
error_code: ErrorCode;
|
||||
message: string;
|
||||
details: Record<string, unknown>;
|
||||
retry_after?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Error Types =====
|
||||
|
||||
export type ErrorCode =
|
||||
| 'SESSION_NOT_FOUND'
|
||||
| 'EXECUTION_FAILED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INVALID_INPUT'
|
||||
| 'SOURCE_UNAVAILABLE'
|
||||
| 'AI_UNAVAILABLE'
|
||||
| 'TIMEOUT'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
export interface APIError {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ===== Standard API Response Envelope =====
|
||||
|
||||
export interface APIResponse<T = unknown> {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
error: APIError | null;
|
||||
meta: {
|
||||
timestamp: string; // ISO 8601
|
||||
request_id: string;
|
||||
rate_limit: {
|
||||
remaining: number;
|
||||
reset_at: string; // ISO 8601
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Meeting Preparation Specific Types =====
|
||||
|
||||
export interface MeetingPrepWorkflow {
|
||||
workflow_type: 'meeting_preparation';
|
||||
inputs: {
|
||||
meeting_title: string;
|
||||
participant: string;
|
||||
time_context: string;
|
||||
document_sources: ('gmail' | 'notion' | 'calendar')[];
|
||||
output_format: 'notion_doc' | 'email_summary' | 'both';
|
||||
};
|
||||
processing_steps: MeetingPrepStep[];
|
||||
}
|
||||
|
||||
export interface MeetingPrepStep {
|
||||
step: 'fetch_gmail' | 'access_notion' | 'analyze_context' | 'consolidate_docs' | 'generate_link';
|
||||
action: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ===== Service Integration Types =====
|
||||
|
||||
export interface ExternalServiceConfig {
|
||||
gmail?: { enabled: boolean; scopes: string[]; rate_limit: number };
|
||||
notion?: { enabled: boolean; workspace_id: string; rate_limit: number };
|
||||
calendar?: { enabled: boolean; calendar_ids: string[] };
|
||||
}
|
||||
|
||||
export interface ServiceAuthStatus {
|
||||
gmail: 'connected' | 'disconnected' | 'error';
|
||||
notion: 'connected' | 'disconnected' | 'error';
|
||||
calendar: 'connected' | 'disconnected' | 'error';
|
||||
}
|
||||
|
||||
// ===== Performance & Monitoring Types =====
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
response_time: number;
|
||||
execution_time: number;
|
||||
external_api_calls: number;
|
||||
tokens_used: number;
|
||||
cost_estimate: number;
|
||||
}
|
||||
|
||||
export interface SessionMetrics {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
started_at: string; // ISO 8601
|
||||
ended_at?: string; // ISO 8601
|
||||
total_messages: number;
|
||||
execution_count: number;
|
||||
performance: PerformanceMetrics;
|
||||
satisfaction_score?: number;
|
||||
}
|
||||
|
||||
// ===== Type Guards =====
|
||||
|
||||
export function isWebSocketMessage<T>(obj: unknown): obj is WebSocketMessage<T> {
|
||||
if (typeof obj !== 'object' || obj === null) return false;
|
||||
const o = obj as Record<string, unknown>;
|
||||
return typeof o.type === 'string' && 'data' in o;
|
||||
}
|
||||
|
||||
export function isAPIResponse<T>(obj: unknown): obj is APIResponse<T> {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
typeof (obj as { success?: unknown }).success === 'boolean' &&
|
||||
('data' in (obj as object) || 'error' in (obj as object)) &&
|
||||
'meta' in (obj as object)
|
||||
);
|
||||
}
|
||||
|
||||
export function isExecutionStepProgressEvent(obj: unknown): obj is ExecutionStepProgressEvent {
|
||||
if (!isWebSocketMessage(obj) || obj.type !== 'execution_step_progress') return false;
|
||||
const d = obj.data;
|
||||
return typeof d === 'object' && d !== null && 'step_id' in d && 'progress_percentage' in d;
|
||||
}
|
||||
|
||||
// ===== Utility Types =====
|
||||
|
||||
export type ChatEventHandler<T = unknown> = (event: WebSocketMessage<T>) => void;
|
||||
|
||||
export interface ChatClientConfig {
|
||||
websocket_url: string;
|
||||
api_base_url: string;
|
||||
auth_token: string;
|
||||
retry_attempts: number;
|
||||
timeout_ms: number;
|
||||
}
|
||||
|
||||
// ===== Frontend State Types =====
|
||||
|
||||
export interface ChatState {
|
||||
session: ChatSessionDetails | null;
|
||||
messages: ChatMessage[];
|
||||
currentFlow: ConversationFlow;
|
||||
isExecuting: boolean;
|
||||
executionProgress: ExecutionStep[];
|
||||
isConnected: boolean;
|
||||
lastError: APIError | null;
|
||||
}
|
||||
|
||||
export interface ChatActions {
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
startExecution: (planId: string) => Promise<void>;
|
||||
connect: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Onboarding flow types and interfaces
|
||||
|
||||
export interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
component: React.ComponentType;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
countryCode?: string;
|
||||
privacySettings?: PrivacySettings;
|
||||
analyticsPreferences?: AnalyticsPreferences;
|
||||
}
|
||||
|
||||
export interface PrivacySettings {
|
||||
enterpriseGradeSecurity: boolean;
|
||||
dataEncryption: boolean;
|
||||
secureBackups: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsPreferences {
|
||||
shareAnalytics: boolean;
|
||||
emailConnected: boolean;
|
||||
maximumPrivacy: boolean;
|
||||
}
|
||||
|
||||
export interface WeatherData {
|
||||
location: string;
|
||||
temperature: number;
|
||||
condition: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
dialCode: string;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export type WebLoginMethod = 'phone' | 'telegram';
|
||||
|
||||
export interface PhoneLoginContext {
|
||||
method: 'phone';
|
||||
phoneNumber: string;
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
// The shape of the Telegram user object is defined by Telegram.
|
||||
// We keep it as unknown here and let the backend interpret it.
|
||||
export interface TelegramLoginContext {
|
||||
method: 'telegram';
|
||||
telegramUser: unknown;
|
||||
}
|
||||
|
||||
export type WebLoginContext = PhoneLoginContext | TelegramLoginContext;
|
||||
|
||||
const DESKTOP_SCHEME = 'openhuman';
|
||||
|
||||
export const buildDesktopDeeplink = (token: string): string => {
|
||||
const encoded = encodeURIComponent(token);
|
||||
return `${DESKTOP_SCHEME}://auth?token=${encoded}`;
|
||||
};
|
||||
|
||||
export const buildPaymentSuccessDeeplink = (sessionId: string): string => {
|
||||
const encoded = encodeURIComponent(sessionId);
|
||||
return `${DESKTOP_SCHEME}://payment/success?session_id=${encoded}`;
|
||||
};
|
||||
|
||||
export const buildPaymentCancelDeeplink = (): string => {
|
||||
return `${DESKTOP_SCHEME}://payment/cancel`;
|
||||
};
|
||||
@@ -1,210 +0,0 @@
|
||||
import type { MCPTool } from '../lib/mcp';
|
||||
import type {
|
||||
BackendActionableItem,
|
||||
BackendChatMessage,
|
||||
ConnectedTool,
|
||||
} from '../services/intelligenceApi';
|
||||
import type {
|
||||
ActionableItem,
|
||||
ActionableItemPriority,
|
||||
ActionableItemSource,
|
||||
ActionableItemStatus,
|
||||
ChatMessage,
|
||||
} from '../types/intelligence';
|
||||
|
||||
/**
|
||||
* Transform backend actionable item to frontend format
|
||||
*/
|
||||
export function transformBackendItemToFrontend(backendItem: BackendActionableItem): ActionableItem {
|
||||
return {
|
||||
id: backendItem.id,
|
||||
title: backendItem.title,
|
||||
description: backendItem.description,
|
||||
source: backendItem.source as ActionableItemSource,
|
||||
priority: backendItem.priority as ActionableItemPriority,
|
||||
status: backendItem.status as ActionableItemStatus,
|
||||
createdAt: new Date(backendItem.createdAt),
|
||||
updatedAt: new Date(backendItem.updatedAt),
|
||||
expiresAt: backendItem.expiresAt ? new Date(backendItem.expiresAt) : undefined,
|
||||
snoozeUntil: backendItem.snoozeUntil ? new Date(backendItem.snoozeUntil) : undefined,
|
||||
actionable: backendItem.actionable,
|
||||
requiresConfirmation: backendItem.requiresConfirmation,
|
||||
hasComplexAction: backendItem.hasComplexAction,
|
||||
dismissedAt: backendItem.dismissedAt ? new Date(backendItem.dismissedAt) : undefined,
|
||||
completedAt: backendItem.completedAt ? new Date(backendItem.completedAt) : undefined,
|
||||
reminderCount: backendItem.reminderCount,
|
||||
// Backend integration fields
|
||||
threadId: backendItem.threadId,
|
||||
executionStatus: backendItem.executionStatus,
|
||||
currentSessionId: backendItem.currentSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform multiple backend items to frontend format
|
||||
*/
|
||||
export function transformBackendItemsToFrontend(
|
||||
backendItems: BackendActionableItem[]
|
||||
): ActionableItem[] {
|
||||
return backendItems.map(transformBackendItemToFrontend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform backend chat message to frontend format
|
||||
*/
|
||||
export function transformBackendMessageToFrontend(backendMessage: BackendChatMessage): ChatMessage {
|
||||
return {
|
||||
id: backendMessage.id,
|
||||
content: backendMessage.content,
|
||||
sender: backendMessage.role === 'user' ? 'user' : 'ai',
|
||||
timestamp: new Date(backendMessage.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform multiple backend messages to frontend format
|
||||
*/
|
||||
export function transformBackendMessagesToFrontend(
|
||||
backendMessages: BackendChatMessage[]
|
||||
): ChatMessage[] {
|
||||
return backendMessages.map(transformBackendMessageToFrontend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform frontend chat message to backend format
|
||||
*/
|
||||
export function transformFrontendMessageToBackend(
|
||||
message: ChatMessage,
|
||||
threadId: string
|
||||
): Omit<BackendChatMessage, 'id'> {
|
||||
return {
|
||||
content: message.content,
|
||||
role: message.sender === 'user' ? 'user' : 'assistant',
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform MCP tools to connected tools format for backend
|
||||
*/
|
||||
export function transformMCPToConnectedTools(mcpTools: MCPTool[]): ConnectedTool[] {
|
||||
return mcpTools.map(tool => {
|
||||
const [skillId, toolName] = tool.name.split('__');
|
||||
|
||||
return {
|
||||
name: toolName || tool.name,
|
||||
description: tool.description,
|
||||
parameters: (tool.inputSchema || {}) as unknown as Record<string, unknown>,
|
||||
skillId: skillId || 'unknown',
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform connected tools back to MCP format
|
||||
*/
|
||||
export function transformConnectedToolsToMCP(connectedTools: ConnectedTool[]): MCPTool[] {
|
||||
return connectedTools.map(tool => ({
|
||||
name: `${tool.skillId}__${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: { type: 'object', properties: tool.parameters || {} },
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate actionable item data from backend
|
||||
*/
|
||||
export function validateBackendItem(item: unknown): item is BackendActionableItem {
|
||||
if (typeof item !== 'object' || item === null) return false;
|
||||
const o = item as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.id === 'string' &&
|
||||
typeof o.title === 'string' &&
|
||||
typeof o.source === 'string' &&
|
||||
typeof o.priority === 'string' &&
|
||||
typeof o.status === 'string' &&
|
||||
typeof o.createdAt === 'string' &&
|
||||
typeof o.updatedAt === 'string' &&
|
||||
typeof o.actionable === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat message in frontend format
|
||||
*/
|
||||
export function createChatMessage(
|
||||
content: string,
|
||||
sender: 'user' | 'ai',
|
||||
id?: string
|
||||
): ChatMessage {
|
||||
return {
|
||||
id: id || `${sender}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
content,
|
||||
sender,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map execution status to user-friendly labels
|
||||
*/
|
||||
export function getExecutionStatusLabel(status?: string): string {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'Ready';
|
||||
case 'running':
|
||||
return 'In Progress';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority display information
|
||||
*/
|
||||
export function getPriorityInfo(priority: ActionableItemPriority): {
|
||||
label: string;
|
||||
className: string;
|
||||
color: string;
|
||||
} {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return { label: 'Critical', className: 'text-coral-400 bg-coral-500/10', color: 'coral' };
|
||||
case 'important':
|
||||
return { label: 'Important', className: 'text-amber-400 bg-amber-500/10', color: 'amber' };
|
||||
case 'normal':
|
||||
return { label: 'Normal', className: 'text-sage-400 bg-sage-500/10', color: 'sage' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source display information
|
||||
*/
|
||||
export function getSourceInfo(source: ActionableItemSource): {
|
||||
label: string;
|
||||
icon: string;
|
||||
className: string;
|
||||
} {
|
||||
switch (source) {
|
||||
case 'email':
|
||||
return { label: 'Email', icon: '📧', className: 'text-blue-400 bg-blue-500/10' };
|
||||
case 'calendar':
|
||||
return { label: 'Calendar', icon: '📅', className: 'text-green-400 bg-green-500/10' };
|
||||
case 'telegram':
|
||||
return { label: 'Telegram', icon: '💬', className: 'text-blue-400 bg-blue-500/10' };
|
||||
case 'ai_insight':
|
||||
return { label: 'AI Insight', icon: '🤖', className: 'text-purple-400 bg-purple-500/10' };
|
||||
case 'system':
|
||||
return { label: 'System', icon: '⚙️', className: 'text-stone-400 bg-stone-500/10' };
|
||||
case 'trading':
|
||||
return { label: 'Trading', icon: '📈', className: 'text-yellow-400 bg-yellow-500/10' };
|
||||
case 'security':
|
||||
return { label: 'Security', icon: '🔒', className: 'text-red-400 bg-red-500/10' };
|
||||
}
|
||||
}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
// Global declarations for E2E tests (WebDriverIO globals)
|
||||
declare namespace NodeJS {
|
||||
interface Global {
|
||||
browser: any;
|
||||
}
|
||||
}
|
||||
|
||||
declare const browser: any;
|
||||
@@ -15,6 +15,8 @@
|
||||
"dev:app": "yarn workspace openhuman-app dev:app",
|
||||
"format": "yarn workspace openhuman-app format",
|
||||
"format:check": "yarn workspace openhuman-app format:check",
|
||||
"knip": "yarn workspace openhuman-app knip",
|
||||
"knip:production": "yarn workspace openhuman-app knip:production",
|
||||
"lint": "yarn workspace openhuman-app lint",
|
||||
"lint:fix": "yarn workspace openhuman-app lint:fix",
|
||||
"prepare": "husky",
|
||||
|
||||
+5
-4
@@ -355,10 +355,11 @@ fn run_namespace_command(
|
||||
};
|
||||
|
||||
// Domain adapters can intercept specific namespace/function combinations.
|
||||
if args.len() > 1 && is_help(&args[1]) {
|
||||
if autocomplete_cli_adapter::maybe_print_start_help(namespace, function) {
|
||||
return Ok(());
|
||||
}
|
||||
if args.len() > 1
|
||||
&& is_help(&args[1])
|
||||
&& autocomplete_cli_adapter::maybe_print_start_help(namespace, function)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(value) =
|
||||
autocomplete_cli_adapter::maybe_handle_namespace_start(namespace, function, &args[1..])?
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ where
|
||||
let time_styled = Style::new().dimmed().paint(time.to_string());
|
||||
write!(writer, "{time_styled}:")?;
|
||||
|
||||
let tag = format!("{level}");
|
||||
let tag = level.to_string();
|
||||
let level_styled = match *meta.level() {
|
||||
Level::ERROR => Style::new().fg(Color::Red).bold().paint(tag),
|
||||
Level::WARN => Style::new().fg(Color::Yellow).bold().paint(tag),
|
||||
@@ -59,7 +59,7 @@ where
|
||||
};
|
||||
write!(writer, "{level_styled}:")?;
|
||||
|
||||
let scope = format!("{target}");
|
||||
let scope = target.to_string();
|
||||
let scope_styled = Style::new().fg(Color::Fixed(247)).paint(scope);
|
||||
write!(writer, "{scope_styled} ")?;
|
||||
} else {
|
||||
|
||||
@@ -236,7 +236,7 @@ fn run_skills_list(args: &[String]) -> Result<()> {
|
||||
if manifests.is_empty() {
|
||||
println!("No skills found.");
|
||||
} else {
|
||||
println!("{:<20} {:<10} {}", "ID", "VERSION", "NAME");
|
||||
println!("{:<20} {:<10} NAME", "ID", "VERSION");
|
||||
println!("{}", "-".repeat(60));
|
||||
for m in &manifests {
|
||||
println!(
|
||||
|
||||
@@ -168,7 +168,7 @@ fn extract_command_name(error: &str) -> Option<String> {
|
||||
|
||||
// Pattern: "'CMD' is not recognized"
|
||||
if error.contains("is not recognized") {
|
||||
let stripped = error.replace('\'', "").replace('"', "");
|
||||
let stripped = error.replace(['\'', '"'], "");
|
||||
if let Some(cmd) = stripped.split_whitespace().next() {
|
||||
if cmd.len() < 64 {
|
||||
return Some(cmd.to_string());
|
||||
|
||||
@@ -11,7 +11,9 @@ pub type TaskId = String;
|
||||
/// Current execution status of a DAG task node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum TaskStatus {
|
||||
#[default]
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
@@ -19,12 +21,6 @@ pub enum TaskStatus {
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl Default for TaskStatus {
|
||||
fn default() -> Self {
|
||||
Self::Pending
|
||||
}
|
||||
}
|
||||
|
||||
/// Request sent from the orchestrator to spawn a sub-agent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubAgentRequest {
|
||||
|
||||
@@ -188,10 +188,7 @@ impl AutocompleteEngine {
|
||||
state.updated_at_ms = Some(Utc::now().timestamp_millis());
|
||||
|
||||
// Only notify if this is a *new* error message.
|
||||
let is_new_error = state
|
||||
.last_notified_error
|
||||
.as_ref()
|
||||
.map_or(true, |prev| prev != &err);
|
||||
let is_new_error = state.last_notified_error.as_ref() != Some(&err);
|
||||
if is_new_error {
|
||||
state.last_notified_error = Some(err.clone());
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ pub(super) fn sanitize_suggestion(text: &str) -> String {
|
||||
}
|
||||
};
|
||||
let cleaned = stripped_prefix
|
||||
.replace('\t', " ")
|
||||
.replace('→', " ")
|
||||
.replace(['\t', '→'], " ")
|
||||
.replace('\r', "")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
|
||||
@@ -12,6 +12,12 @@ use serde_json::json;
|
||||
/// sending replies back to the originating channel via the backend REST API.
|
||||
pub struct ChannelInboundSubscriber;
|
||||
|
||||
impl Default for ChannelInboundSubscriber {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChannelInboundSubscriber {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
|
||||
@@ -277,8 +277,8 @@ fn split_sentences(text: &str) -> Vec<String> {
|
||||
|
||||
/// Group sentences into 2-3 bubbles.
|
||||
fn group_sentences(sentences: &[String]) -> Vec<String> {
|
||||
let target_count = std::cmp::min(3, (sentences.len() + 1) / 2);
|
||||
let group_size = (sentences.len() + target_count - 1) / target_count;
|
||||
let target_count = std::cmp::min(3, sentences.len().div_ceil(2));
|
||||
let group_size = sentences.len().div_ceil(target_count);
|
||||
let mut groups: Vec<String> = Vec::new();
|
||||
|
||||
for chunk in sentences.chunks(group_size) {
|
||||
|
||||
@@ -6,19 +6,15 @@ use serde::{Deserialize, Serialize};
|
||||
/// Activation mode for the dictation hotkey.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[derive(Default)]
|
||||
pub enum DictationActivationMode {
|
||||
/// Press once to start, press again to stop.
|
||||
Toggle,
|
||||
/// Hold to record, release to stop (push-to-talk).
|
||||
#[default]
|
||||
Push,
|
||||
}
|
||||
|
||||
impl Default for DictationActivationMode {
|
||||
fn default() -> Self {
|
||||
Self::Push
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct DictationConfig {
|
||||
/// Whether voice dictation is enabled.
|
||||
|
||||
@@ -30,7 +30,7 @@ struct ActiveWorkspaceState {
|
||||
}
|
||||
|
||||
fn default_config_dir() -> Result<PathBuf> {
|
||||
Ok(default_root_openhuman_dir()?)
|
||||
default_root_openhuman_dir()
|
||||
}
|
||||
|
||||
/// Returns the root openhuman directory (`~/.openhuman`), independent of any
|
||||
|
||||
@@ -47,7 +47,7 @@ pub struct OrchestratorConfig {
|
||||
/// Per-archetype configuration override.
|
||||
///
|
||||
/// Any field left `None` uses the archetype's built-in default.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
|
||||
pub struct ArchetypeConfig {
|
||||
/// Model name or hint override (e.g. "coding-v1", "local:phi3").
|
||||
#[serde(default)]
|
||||
@@ -104,16 +104,3 @@ impl Default for OrchestratorConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ArchetypeConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: None,
|
||||
system_prompt: None,
|
||||
temperature: None,
|
||||
max_tool_iterations: None,
|
||||
timeout_secs: None,
|
||||
sandbox: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ impl Default for IntegrationToggle {
|
||||
/// location search (Google Places), and phone calls (Twilio). The backend
|
||||
/// handles external API calls, billing, and rate limiting; the client only
|
||||
/// forwards requests and displays results.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
|
||||
pub struct IntegrationsConfig {
|
||||
/// Master switch — set to `true` to register integration tools.
|
||||
#[serde(default)]
|
||||
@@ -286,16 +286,3 @@ pub struct IntegrationsConfig {
|
||||
#[serde(default)]
|
||||
pub parallel: IntegrationToggle,
|
||||
}
|
||||
|
||||
impl Default for IntegrationsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
backend_url: None,
|
||||
auth_token: None,
|
||||
twilio: IntegrationToggle::default(),
|
||||
google_places: IntegrationToggle::default(),
|
||||
parallel: IntegrationToggle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,15 @@ use serde::{Deserialize, Serialize};
|
||||
/// Activation mode for the voice server hotkey.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum VoiceActivationMode {
|
||||
/// Single press toggles recording on/off.
|
||||
Tap,
|
||||
/// Hold to record, release to stop.
|
||||
#[default]
|
||||
Push,
|
||||
}
|
||||
|
||||
impl Default for VoiceActivationMode {
|
||||
fn default() -> Self {
|
||||
Self::Push
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the voice dictation server.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct VoiceServerConfig {
|
||||
|
||||
@@ -19,15 +19,8 @@ pub(crate) fn parse_suggestions(raw: &str, limit: usize) -> Vec<Suggestion> {
|
||||
|
||||
fn normalize_inline_text(value: &str) -> String {
|
||||
value
|
||||
.replace('\u{200B}', "")
|
||||
.replace('\u{200C}', "")
|
||||
.replace('\u{200D}', "")
|
||||
.replace('\u{FEFF}', "")
|
||||
.replace('\u{00A0}', " ")
|
||||
.replace('\u{2028}', " ")
|
||||
.replace('\u{2029}', " ")
|
||||
.replace('\t', " ")
|
||||
.replace('→', " ")
|
||||
.replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "")
|
||||
.replace(['\u{00A0}', '\u{2028}', '\u{2029}', '\t', '→'], " ")
|
||||
}
|
||||
|
||||
fn trim_generation_prefixes(mut value: &str) -> &str {
|
||||
@@ -37,7 +30,7 @@ fn trim_generation_prefixes(mut value: &str) -> &str {
|
||||
for prefix in ["suffix:", "completion:", "result:", "output:"] {
|
||||
if value
|
||||
.get(..prefix.len())
|
||||
.map_or(false, |s| s.eq_ignore_ascii_case(prefix))
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case(prefix))
|
||||
{
|
||||
value = value.get(prefix.len()..).unwrap_or(value).trim_start();
|
||||
break;
|
||||
|
||||
@@ -352,7 +352,7 @@ fn decode_wav_to_f32(data: &[u8]) -> Result<Vec<f32>, String> {
|
||||
}
|
||||
|
||||
pos += 8 + chunk_size;
|
||||
if chunk_size % 2 != 0 {
|
||||
if !chunk_size.is_multiple_of(2) {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ pub fn events_for_segment(
|
||||
ORDER BY created_at ASC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![segment_id], |row| row_to_event(row))?
|
||||
.query_map(params![segment_id], row_to_event)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ pub fn extract_events_heuristic(text: &str) -> Vec<(EventType, String)> {
|
||||
|
||||
// Split into sentences (rough heuristic).
|
||||
let sentences: Vec<&str> = text
|
||||
.split(|c: char| c == '.' || c == '!' || c == '?' || c == '\n')
|
||||
.split(['.', '!', '?', '\n'])
|
||||
.map(str::trim)
|
||||
.filter(|s| s.len() > 5)
|
||||
.collect();
|
||||
|
||||
@@ -189,7 +189,7 @@ pub fn profile_load_all(conn: &Arc<Mutex<Connection>>) -> anyhow::Result<Vec<Pro
|
||||
ORDER BY facet_type, evidence_count DESC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row_to_facet(row))?
|
||||
.query_map([], row_to_facet)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -208,7 +208,7 @@ pub fn profile_facets_by_type(
|
||||
ORDER BY evidence_count DESC",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![facet_type.as_str()], |row| row_to_facet(row))?
|
||||
.query_map(params![facet_type.as_str()], row_to_facet)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ pub fn open_segment_for_session(
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
params![session_id],
|
||||
|row| row_to_segment(row),
|
||||
row_to_segment,
|
||||
)
|
||||
.optional()?;
|
||||
Ok(row)
|
||||
@@ -308,7 +308,7 @@ pub fn segments_by_namespace(
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![namespace, limit as i64], |row| row_to_segment(row))?
|
||||
.query_map(params![namespace, limit as i64], row_to_segment)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -327,7 +327,7 @@ pub fn segment_get(
|
||||
FROM conversation_segments
|
||||
WHERE segment_id = ?1",
|
||||
params![segment_id],
|
||||
|row| row_to_segment(row),
|
||||
row_to_segment,
|
||||
)
|
||||
.optional()?;
|
||||
Ok(row)
|
||||
@@ -349,7 +349,7 @@ pub fn segments_pending_summary(
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit as i64], |row| row_to_segment(row))?
|
||||
.query_map(params![limit as i64], row_to_segment)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
@@ -623,7 +623,7 @@ pub(crate) async fn handle_js_call(
|
||||
}
|
||||
|
||||
// Log progress every 2 seconds so we can see if the loop is running
|
||||
if poll_count % 400 == 0 {
|
||||
if poll_count.is_multiple_of(400) {
|
||||
let elapsed = 30
|
||||
- deadline
|
||||
.duration_since(tokio::time::Instant::now())
|
||||
|
||||
@@ -185,7 +185,7 @@ pub fn register<'js>(
|
||||
}
|
||||
|
||||
{
|
||||
let ws = ws_state.clone();
|
||||
let _ws = ws_state.clone();
|
||||
ops.set(
|
||||
"ws_recv",
|
||||
Function::new(ctx.clone(), move |_id: u32| {
|
||||
|
||||
@@ -224,7 +224,7 @@ impl ToolResult {
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
ToolContent::Text { text } => Some(text.as_str()),
|
||||
ToolContent::Json { data } => None,
|
||||
ToolContent::Json { data: _ } => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
|
||||
@@ -195,7 +195,7 @@ fn handle_state(_params: Map<String, Value>) -> ControllerFuture {
|
||||
let mgr = require_manager()?;
|
||||
let state = mgr.get_state();
|
||||
log::debug!("[socket:rpc] state → {:?}", state.status);
|
||||
Ok(serde_json::to_value(state).map_err(|e| format!("serialize: {e}"))?)
|
||||
serde_json::to_value(state).map_err(|e| format!("serialize: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,19 +56,18 @@ impl SubconsciousEngine {
|
||||
) -> Self {
|
||||
// Seed default system tasks eagerly so they show in the UI immediately,
|
||||
// without waiting for the first tick.
|
||||
let seeded =
|
||||
match store::with_connection(&workspace_dir, |conn| store::seed_default_tasks(conn)) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
info!("[subconscious] seeded {count} tasks on init");
|
||||
}
|
||||
true
|
||||
let seeded = match store::with_connection(&workspace_dir, store::seed_default_tasks) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
info!("[subconscious] seeded {count} tasks on init");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[subconscious] seed on init failed: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("[subconscious] seed on init failed: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
workspace_dir,
|
||||
@@ -416,7 +415,7 @@ impl SubconsciousEngine {
|
||||
// ── Internal methods ─────────────────────────────────────────────────────
|
||||
|
||||
fn seed_tasks(&self) {
|
||||
match store::with_connection(&self.workspace_dir, |conn| store::seed_default_tasks(conn)) {
|
||||
match store::with_connection(&self.workspace_dir, store::seed_default_tasks) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
info!("[subconscious] seeded {count} default tasks");
|
||||
|
||||
@@ -93,9 +93,8 @@ pub async fn bootstrap_after_login() -> Result<(), String> {
|
||||
// SubconsciousEngine::new calls seed_default_tasks() inside the
|
||||
// constructor, so by the time this returns the 3 system defaults are
|
||||
// present in `<workspace>/subconscious/subconscious.db`.
|
||||
get_or_init_engine().await.map_err(|e| {
|
||||
get_or_init_engine().await.inspect_err(|_e| {
|
||||
BOOTSTRAPPED.store(false, Ordering::SeqCst);
|
||||
e
|
||||
})?;
|
||||
tracing::info!(
|
||||
workspace = %config.workspace_dir.display(),
|
||||
|
||||
@@ -10,6 +10,12 @@ use serde_json::json;
|
||||
/// event channel and waits for a response before continuing.
|
||||
pub struct AskClarificationTool;
|
||||
|
||||
impl Default for AskClarificationTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AskClarificationTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
|
||||
@@ -9,6 +9,12 @@ use serde_json::json;
|
||||
/// Available only to the Orchestrator archetype.
|
||||
pub struct SpawnSubagentTool;
|
||||
|
||||
impl Default for SpawnSubagentTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl SpawnSubagentTool {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
@@ -63,7 +69,7 @@ impl Tool for SpawnSubagentTool {
|
||||
|
||||
let prompt = args.get("prompt").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let context = args.get("context").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let _context = args.get("context").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if prompt.is_empty() {
|
||||
return Ok(ToolResult::error("prompt is required"));
|
||||
|
||||
@@ -9,6 +9,12 @@ use async_trait::async_trait;
|
||||
/// Subscribes to tree summarizer events and logs activity.
|
||||
pub struct TreeSummarizerEventSubscriber;
|
||||
|
||||
impl Default for TreeSummarizerEventSubscriber {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TreeSummarizerEventSubscriber {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
|
||||
@@ -294,8 +294,8 @@ pub fn get_tree_status(config: &Config, namespace: &str) -> Result<TreeStatus> {
|
||||
// Try to parse timestamp from path
|
||||
if let Some(ts) = timestamp_from_hour_path(
|
||||
&name,
|
||||
&month_entry.file_name().to_string_lossy().to_string(),
|
||||
&day_entry.file_name().to_string_lossy().to_string(),
|
||||
month_entry.file_name().to_string_lossy().as_ref(),
|
||||
day_entry.file_name().to_string_lossy().as_ref(),
|
||||
&hname,
|
||||
) {
|
||||
match &oldest {
|
||||
@@ -672,10 +672,8 @@ fn count_md_files(dir: &Path) -> Result<u64> {
|
||||
continue; // skip buffer directories
|
||||
}
|
||||
count += count_md_files(&entry.path())?;
|
||||
} else if ft.is_file() {
|
||||
if entry.path().extension().map(|e| e == "md").unwrap_or(false) {
|
||||
count += 1;
|
||||
}
|
||||
} else if ft.is_file() && entry.path().extension().map(|e| e == "md").unwrap_or(false) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
|
||||
@@ -126,7 +126,7 @@ pub struct QueryResult {
|
||||
|
||||
/// Rough token estimate: ~4 characters per token.
|
||||
pub fn estimate_tokens(text: &str) -> u32 {
|
||||
(text.len() as u32 + 3) / 4
|
||||
(text.len() as u32).div_ceil(4)
|
||||
}
|
||||
|
||||
/// Derive the parent node ID from a node ID.
|
||||
|
||||
@@ -304,7 +304,7 @@ fn record_on_thread(
|
||||
};
|
||||
|
||||
// If the preferred config failed, retry with the device's default config.
|
||||
let (stream, source_sample_rate, source_channels) = match stream {
|
||||
let (stream, source_sample_rate, _source_channels) = match stream {
|
||||
Ok(s) => (s, source_sample_rate, source_channels),
|
||||
Err(ref preferred_err) => {
|
||||
warn!(
|
||||
|
||||
@@ -21,19 +21,15 @@ const LOG_PREFIX: &str = "[voice_hotkey]";
|
||||
Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum ActivationMode {
|
||||
/// Single press toggles recording on/off.
|
||||
Tap,
|
||||
/// Hold to record, release to stop.
|
||||
#[default]
|
||||
Push,
|
||||
}
|
||||
|
||||
impl Default for ActivationMode {
|
||||
fn default() -> Self {
|
||||
Self::Push
|
||||
}
|
||||
}
|
||||
|
||||
/// Events emitted by the hotkey listener.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HotkeyEvent {
|
||||
|
||||
@@ -27,6 +27,12 @@ fn error_body(message: &str) -> String {
|
||||
/// flow: lookup tunnel → dispatch to skill/echo → emit response via socket.
|
||||
pub struct WebhookRequestSubscriber;
|
||||
|
||||
impl Default for WebhookRequestSubscriber {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebhookRequestSubscriber {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
|
||||
Reference in New Issue
Block a user