mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Merge remote-tracking branch 'upstream/develop' into develop
This commit is contained in:
-459
@@ -1,459 +0,0 @@
|
||||
---
|
||||
|
||||
```md
|
||||
# Cross-Platform Tauri Agent Assistant — MVP Specification
|
||||
|
||||
> **Current product docs:** we **ship and support desktop only** (Windows, macOS, Linux). This spec still describes a **broader platform vision** (including mobile) for engineering planning—it is not a promise of availability on Android/iOS or web yet.
|
||||
|
||||
## Overview
|
||||
|
||||
This MVP defines a **Telegram-based Agent Assistant** built with **Tauri (Rust + Web UI)** targeting:
|
||||
|
||||
- Windows
|
||||
- macOS
|
||||
- Android
|
||||
- iOS
|
||||
|
||||
The assistant:
|
||||
- Interacts with users via a **Telegram bot (DM-first)**
|
||||
- Processes Telegram channel data **locally on device**
|
||||
- Uses a **minimal backend** only for:
|
||||
- identity & login
|
||||
- payments & entitlements
|
||||
- push notifications (especially for iOS)
|
||||
- Avoids storing Telegram message content on servers
|
||||
|
||||
---
|
||||
|
||||
## Core Platform Behavior Summary
|
||||
|
||||
| Platform | Listening Model | Trigger to Respond |
|
||||
| -------- | ------------------------------- | -------------------------- |
|
||||
| Windows | Continuous (background) | Bot DM or channel activity |
|
||||
| macOS | Continuous (background) | Bot DM or channel activity |
|
||||
| Android | Continuous (foreground service) | Bot DM or channel activity |
|
||||
| iOS | On-demand only | Bot DM → push → tap → sync |
|
||||
|
||||
---
|
||||
|
||||
## Architectural Pillars
|
||||
|
||||
- **UI-first development**
|
||||
- **Single Rust agent runtime**
|
||||
- **Telegram bot as the user interface**
|
||||
- **Privacy-first local processing**
|
||||
- **Backend as infrastructure, not intelligence**
|
||||
|
||||
---
|
||||
|
||||
# PHASED MVP PLAN
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Project Skeleton & Tooling
|
||||
|
||||
### Goals
|
||||
|
||||
- Prepare repo structure
|
||||
- Establish documentation and contribution rules
|
||||
- No business logic yet
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Monorepo structure
|
||||
- Tauri project scaffold
|
||||
- Mobile targets enabled (Tauri v2)
|
||||
- CI hooks (optional)
|
||||
|
||||
### Repo Structure
|
||||
|
||||
```
|
||||
|
||||
/apps
|
||||
/desktop
|
||||
/mobile
|
||||
/core
|
||||
/agent-runtime (Rust)
|
||||
/storage
|
||||
/telegram
|
||||
/backend
|
||||
/docs
|
||||
|
||||
```
|
||||
|
||||
### Documentation Commands (Required)
|
||||
|
||||
```bash
|
||||
/docs/architecture.md # high-level system design
|
||||
/docs/decisions/ADR-000.md # initial architecture decision record
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- App builds and runs (blank UI)
|
||||
- Docs folder initialized
|
||||
- ADR process agreed upon
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — UI-First MVP (No Logic)
|
||||
|
||||
### Goals
|
||||
|
||||
Build the **entire UI flow** before implementing logic.
|
||||
|
||||
### UI Screens
|
||||
|
||||
- Login / Signup
|
||||
- Telegram Connect (bot instructions)
|
||||
- Channel Selection
|
||||
- Sync Status Screen
|
||||
- Settings (background, privacy, storage)
|
||||
- Plan & Billing (stub)
|
||||
- Logs / Activity View (local only)
|
||||
|
||||
### Platforms
|
||||
|
||||
- All platforms (desktop + mobile)
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Responsive UI
|
||||
- Navigation between screens
|
||||
- Mock data only
|
||||
- No Telegram, no backend, no Rust logic
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/ui/flows.md # user flows
|
||||
/docs/ui/screens.md # screen definitions
|
||||
/docs/ui/states.md # loading / error / empty states
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- Entire app is navigable
|
||||
- No dead-end screens
|
||||
- UI approved before logic begins
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Local Agent Runtime (Rust Only)
|
||||
|
||||
### Goals
|
||||
|
||||
Implement the **local agent engine** without Telegram or backend.
|
||||
|
||||
### Components
|
||||
|
||||
- Rust agent runtime
|
||||
- Intent router (question / sync / config)
|
||||
- Processing pipeline (stubbed)
|
||||
- Response composer (mock responses)
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Tauri IPC commands:
|
||||
- `agent_init`
|
||||
- `agent_process_query`
|
||||
- `agent_status`
|
||||
|
||||
- In-memory only state
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/core/agent.md # agent architecture
|
||||
/docs/core/events.md # internal event types
|
||||
/docs/core/state.md # memory state model
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- UI can send a question
|
||||
- Agent returns a mock response
|
||||
- No persistence yet
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Local Storage & Privacy Layer
|
||||
|
||||
### Goals
|
||||
|
||||
Add **efficient, privacy-first local storage**.
|
||||
|
||||
### Storage Rules
|
||||
|
||||
- No Telegram message bodies by default
|
||||
- Store only:
|
||||
- channel IDs
|
||||
- last processed message IDs
|
||||
- dedupe hashes
|
||||
|
||||
- Encrypted at rest
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Encrypted SQLite
|
||||
- OS keychain integration
|
||||
- Storage abstraction in Rust
|
||||
- “Ephemeral mode” toggle
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/storage/schema.md
|
||||
/docs/storage/encryption.md
|
||||
/docs/privacy/model.md
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- App restarts without losing cursors
|
||||
- “Delete local data” wipes all state
|
||||
- No plaintext sensitive data on disk
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Telegram Bot Integration (Agent Assistant)
|
||||
|
||||
### Goals
|
||||
|
||||
Turn the app into a **real Telegram agent assistant**.
|
||||
|
||||
### Telegram Capabilities (MVP)
|
||||
|
||||
- Bot DM interaction
|
||||
- Read user questions
|
||||
- Fetch channel messages (where bot has access)
|
||||
- Reply via DM
|
||||
|
||||
### Platform Behavior
|
||||
|
||||
- Windows/macOS/Android: continuous polling
|
||||
- iOS: no polling (on-demand only)
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Telegram Bot Gateway (Rust)
|
||||
- Update polling / fetching
|
||||
- Message dedupe + cursoring
|
||||
- Agent replies sent via bot DM
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/telegram/bot.md
|
||||
/docs/telegram/flows.md
|
||||
/docs/telegram/limits.md
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- User asks a question in Telegram
|
||||
- App processes it
|
||||
- Bot replies correctly
|
||||
- No duplicate replies
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Platform Background Execution
|
||||
|
||||
### Goals
|
||||
|
||||
Enable **platform-appropriate background behavior**.
|
||||
|
||||
### Platform Breakdown
|
||||
|
||||
#### Windows
|
||||
|
||||
- Tray app
|
||||
- Autostart
|
||||
- Background polling
|
||||
|
||||
#### macOS
|
||||
|
||||
- Menu bar app
|
||||
- Launch at login
|
||||
|
||||
#### Android
|
||||
|
||||
- Foreground service (persistent notification)
|
||||
- Background polling allowed
|
||||
|
||||
#### iOS
|
||||
|
||||
- ❌ No continuous background
|
||||
- Only foreground execution
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/platforms/windows.md
|
||||
/docs/platforms/macos.md
|
||||
/docs/platforms/android.md
|
||||
/docs/platforms/ios.md
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- Desktop apps run without UI open
|
||||
- Android foreground service stable
|
||||
- iOS behaves strictly foreground-only
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Minimal Backend Integration
|
||||
|
||||
### Goals
|
||||
|
||||
Introduce backend **without violating privacy goals**.
|
||||
|
||||
### Backend Responsibilities
|
||||
|
||||
- Authentication
|
||||
- Device registration
|
||||
- Entitlements
|
||||
- Push notifications
|
||||
- Payment verification
|
||||
|
||||
### Explicit Non-Responsibilities
|
||||
|
||||
- No Telegram message storage
|
||||
- No agent logic
|
||||
- No summaries
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Auth flow wired into UI
|
||||
- Entitlements fetched on startup
|
||||
- Device registered for push
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/backend/api.md
|
||||
/docs/backend/data-model.md
|
||||
/docs/backend/security.md
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- User login works
|
||||
- Entitlements enforced locally
|
||||
- Backend DB contains no message content
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — iOS Push → Tap → Sync Flow
|
||||
|
||||
### Goals
|
||||
|
||||
Implement the **iOS-specific agent interaction model**.
|
||||
|
||||
### Flow
|
||||
|
||||
1. User sends question to bot
|
||||
2. Backend triggers visible push
|
||||
3. User taps notification
|
||||
4. App opens
|
||||
5. App syncs Telegram
|
||||
6. Agent processes
|
||||
7. Bot replies
|
||||
|
||||
### Deliverables
|
||||
|
||||
- APNs integration
|
||||
- Push payload handling
|
||||
- Sync-on-open logic
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/ios/push-flow.md
|
||||
/docs/ios/limitations.md
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- Push reliably opens app
|
||||
- Sync runs automatically
|
||||
- Bot replies successfully
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Payments & Plan Gating
|
||||
|
||||
### Goals
|
||||
|
||||
Monetize safely and correctly.
|
||||
|
||||
### Platforms
|
||||
|
||||
- Desktop: Stripe / Paddle
|
||||
- Android: Play Billing
|
||||
- iOS: StoreKit
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Purchase flow per platform
|
||||
- Receipt verification
|
||||
- Feature gating in Rust
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/billing/plans.md
|
||||
/docs/billing/verification.md
|
||||
/docs/billing/entitlements.md
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- Paid features unlock correctly
|
||||
- Downgrades enforced
|
||||
- Offline grace period handled
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Hardening & Release Prep
|
||||
|
||||
### Goals
|
||||
|
||||
Stability, observability, and trust.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Error handling
|
||||
- Rate limiting
|
||||
- Abuse prevention
|
||||
- Crash-safe storage
|
||||
- UX polish
|
||||
|
||||
### Documentation Commands
|
||||
|
||||
```bash
|
||||
/docs/release/checklist.md
|
||||
/docs/known-issues.md
|
||||
/docs/security/threat-model.md
|
||||
```
|
||||
|
||||
### Exit Criteria
|
||||
|
||||
- No critical crashes
|
||||
- No duplicate Telegram replies
|
||||
- Clear user-facing error states
|
||||
|
||||
---
|
||||
|
||||
## Final Notes
|
||||
|
||||
- The **Telegram bot is the product interface**
|
||||
- The **Tauri app is the execution engine**
|
||||
- The **backend is infrastructure, not intelligence**
|
||||
- iOS behavior is intentionally constrained for correctness
|
||||
|
||||
---
|
||||
|
||||
END OF MVP
|
||||
@@ -1,350 +0,0 @@
|
||||
# Daemon Lifecycle Management System
|
||||
|
||||
**Status**: ✅ Implemented
|
||||
**Version**: 1.0.0
|
||||
**Date**: February 2026
|
||||
|
||||
## Overview
|
||||
|
||||
The Daemon Lifecycle Management System provides comprehensive frontend integration with the alphahuman Rust daemon, enabling real-time health monitoring, automatic lifecycle management, and user-friendly daemon controls throughout the application.
|
||||
|
||||
## Background
|
||||
|
||||
### Problem Statement
|
||||
|
||||
The alphahuman application runs a sophisticated Rust daemon that manages critical backend services (gateway, channels, heartbeat, scheduler) and emits detailed health information every 5 seconds. However, the frontend previously had:
|
||||
|
||||
- **Poor Visibility**: Daemon controls buried in developer console only
|
||||
- **No Real-Time Monitoring**: Manual status checks with raw JSON output
|
||||
- **No Automatic Management**: No startup detection or error recovery
|
||||
- **Disconnected UX**: Health events from Rust were ignored by frontend
|
||||
- **Manual Recovery**: Users had to manually restart failed services
|
||||
|
||||
### Solution Overview
|
||||
|
||||
This implementation creates a complete bridge between the Rust daemon's health system and the React frontend, providing:
|
||||
|
||||
- Real-time health monitoring with visual indicators
|
||||
- Automatic daemon startup and error recovery
|
||||
- User-friendly health displays in main UI
|
||||
- Enhanced developer tools with live status
|
||||
- Coordinated daemon and socket connection management
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ React Frontend │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ UI Layer │
|
||||
│ ├── DaemonHealthIndicator (Main UI) │
|
||||
│ ├── DaemonHealthPanel (Detailed View) │
|
||||
│ └── Enhanced TauriCommandsPanel (Dev Tools) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ State Management │
|
||||
│ ├── daemonSlice.ts (Redux) │
|
||||
│ ├── useDaemonHealth.ts (React Hook) │
|
||||
│ └── useDaemonLifecycle.ts (Lifecycle Hook) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Services │
|
||||
│ └── daemonHealthService.ts (Event Processing) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Integration Layer │
|
||||
│ ├── tauriSocket.ts (Event Listening) │
|
||||
│ └── SocketProvider.tsx (Coordinated State) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
Tauri Event Bridge
|
||||
│
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Rust Daemon │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Health System (src-tauri/src/alphahuman/health/mod.rs) │
|
||||
│ ├── Component Health Tracking │
|
||||
│ ├── Health Snapshot Generation │
|
||||
│ └── Event Emission (every 5s) │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Daemon Supervisor (src-tauri/src/alphahuman/daemon/mod.rs) │
|
||||
│ ├── Component Management │
|
||||
│ ├── Automatic Restarts │
|
||||
│ └── Exponential Backoff │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Components │
|
||||
│ ├── Gateway (API Server) │
|
||||
│ ├── Channels (Communication) │
|
||||
│ ├── Heartbeat (Health Monitor) │
|
||||
│ └── Scheduler (Cron Jobs) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Event Flow
|
||||
|
||||
1. **Health Generation**: Rust daemon generates health snapshots every 5 seconds
|
||||
2. **Event Emission**: Health data emitted via `alphahuman:health` Tauri event
|
||||
3. **Frontend Processing**: `daemonHealthService` receives and parses events
|
||||
4. **State Updates**: Redux state updated with component health information
|
||||
5. **UI Reactivity**: React components automatically re-render with new status
|
||||
6. **User Actions**: Manual controls trigger Tauri commands back to Rust
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Redux State Management
|
||||
|
||||
**File**: `src/store/daemonSlice.ts`
|
||||
|
||||
```typescript
|
||||
interface DaemonUserState {
|
||||
status: 'starting' | 'running' | 'error' | 'disconnected';
|
||||
healthSnapshot: HealthSnapshot | null;
|
||||
components: {
|
||||
gateway?: ComponentHealth;
|
||||
channels?: ComponentHealth;
|
||||
heartbeat?: ComponentHealth;
|
||||
scheduler?: ComponentHealth;
|
||||
};
|
||||
lastHealthUpdate: string | null;
|
||||
connectionAttempts: number;
|
||||
autoStartEnabled: boolean;
|
||||
isRecovering: boolean;
|
||||
healthTimeoutId: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
|
||||
- Per-user daemon state isolation (following existing patterns)
|
||||
- Component-level health tracking with error details
|
||||
- Connection attempt management with exponential backoff
|
||||
- Auto-start preference persistence
|
||||
- Timeout handling for health event detection
|
||||
|
||||
### Health Monitoring Service
|
||||
|
||||
**File**: `src/services/daemonHealthService.ts`
|
||||
|
||||
```typescript
|
||||
export class DaemonHealthService {
|
||||
private healthTimeoutId: NodeJS.Timeout | null = null;
|
||||
private readonly HEALTH_TIMEOUT_MS = 30000; // 30 seconds
|
||||
|
||||
async setupHealthListener(): Promise<UnlistenFn | null>;
|
||||
private parseHealthSnapshot(payload: unknown): HealthSnapshot | null;
|
||||
private updateReduxFromHealth(snapshot: HealthSnapshot): void;
|
||||
startHealthTimeout(): void;
|
||||
}
|
||||
```
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
- Listen for `alphahuman:health` Tauri events from Rust
|
||||
- Parse and validate health snapshot data
|
||||
- Update Redux state with component health information
|
||||
- Manage 30-second timeout detection for disconnected daemon
|
||||
- Handle cleanup and error recovery
|
||||
|
||||
### UI Components
|
||||
|
||||
#### DaemonHealthIndicator
|
||||
|
||||
**File**: `src/components/daemon/DaemonHealthIndicator.tsx`
|
||||
|
||||
**Purpose**: Compact status indicator for main application UI
|
||||
|
||||
**Visual States**:
|
||||
|
||||
- 🟢 **Green**: All components running healthy (`status: 'running'`)
|
||||
- 🟡 **Yellow**: Daemon starting or recovering (`status: 'starting'`)
|
||||
- 🔴 **Red**: One or more components in error state (`status: 'error'`)
|
||||
- ⚪ **Gray**: Daemon disconnected or not running (`status: 'disconnected'`)
|
||||
|
||||
**Features**:
|
||||
|
||||
- Click to open detailed health panel
|
||||
- Tooltip showing component health summary
|
||||
- Responsive sizing (sm/md/lg variants)
|
||||
- Only visible in Tauri environments
|
||||
|
||||
#### DaemonHealthPanel
|
||||
|
||||
**File**: `src/components/daemon/DaemonHealthPanel.tsx`
|
||||
|
||||
**Purpose**: Detailed health breakdown and manual controls
|
||||
|
||||
**Features**:
|
||||
|
||||
- Component health table with status, last update, restart counts
|
||||
- Manual restart buttons for individual components
|
||||
- Auto-start toggle with persistence
|
||||
- Connection retry controls
|
||||
- Real-time health information display
|
||||
- User-friendly error messages and troubleshooting hints
|
||||
|
||||
### Lifecycle Management
|
||||
|
||||
**File**: `src/hooks/useDaemonLifecycle.ts`
|
||||
|
||||
**Automatic Behaviors**:
|
||||
|
||||
1. **App Startup**:
|
||||
- Detect existing daemon status
|
||||
- Auto-start daemon if enabled and not running
|
||||
- Setup health monitoring listeners
|
||||
|
||||
2. **Error Recovery**:
|
||||
- Exponential backoff retry attempts (1s → 2s → 4s → 8s → 30s max)
|
||||
- Maximum 5 retry attempts before requiring manual intervention
|
||||
- Clear error states on successful recovery
|
||||
|
||||
3. **Background Handling**:
|
||||
- Maintain health monitoring when app backgrounded
|
||||
- Reconnection logic without page refresh
|
||||
- Coordinate with socket connection management
|
||||
|
||||
4. **Cleanup**:
|
||||
- Proper event listener cleanup on unmount
|
||||
- Timeout cancellation to prevent memory leaks
|
||||
- Graceful shutdown coordination
|
||||
|
||||
### Integration Points
|
||||
|
||||
#### Enhanced Service Management
|
||||
|
||||
**File**: `src/components/settings/panels/TauriCommandsPanel.tsx`
|
||||
|
||||
**Improvements to Existing Developer Console**:
|
||||
|
||||
- **Live Status Display**: Real-time daemon status with PID and uptime
|
||||
- **Component Health Grid**: Visual status for all daemon components
|
||||
- **Enhanced Controls**: Smart start/stop buttons with proper loading states
|
||||
- **Auto-Start Toggle**: User preference for automatic daemon startup
|
||||
- **Connection Tracking**: Display retry attempts and recovery status
|
||||
- **Better Error Messages**: User-friendly errors with troubleshooting hints
|
||||
|
||||
**Before vs After**:
|
||||
|
||||
```typescript
|
||||
// Before: Manual status check
|
||||
<PrimaryButton onClick={() => run(alphahumanServiceStatus, 'serviceStatus')}>
|
||||
Status
|
||||
</PrimaryButton>
|
||||
|
||||
// After: Live status display
|
||||
<div className="flex items-center gap-3">
|
||||
<DaemonHealthIndicator size="md" />
|
||||
<div>
|
||||
<div className="text-white font-medium">Daemon Status: {status}</div>
|
||||
<div className="text-xs text-gray-400">PID: {pid} | Uptime: {uptime}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Socket Provider Integration
|
||||
|
||||
**File**: `src/providers/SocketProvider.tsx`
|
||||
|
||||
**Coordinated State Management**:
|
||||
|
||||
- Check daemon health before attempting socket connections
|
||||
- Display daemon-related errors in socket connection status
|
||||
- Coordinate daemon startup with socket connection flows
|
||||
- Provide daemon health context to socket consumers
|
||||
|
||||
#### Main UI Integration
|
||||
|
||||
**File**: `src/components/MiniSidebar.tsx`
|
||||
|
||||
**User-Facing Integration**:
|
||||
|
||||
- Daemon health indicator in main navigation (Tauri-only)
|
||||
- Click to open detailed health modal
|
||||
- Non-intrusive but easily accessible
|
||||
- Consistent with existing UI patterns
|
||||
|
||||
## User Experience
|
||||
|
||||
### For End Users
|
||||
|
||||
1. **Visible Status**: Daemon health indicator in main UI shows system status at a glance
|
||||
2. **Automatic Operation**: Daemon starts automatically and recovers from errors without user intervention
|
||||
3. **Clear Feedback**: User-friendly messages explain daemon state and provide actionable guidance
|
||||
4. **Quick Access**: Click health indicator to see detailed component status and manual controls
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Enhanced Console**: Improved service management in settings with live status updates
|
||||
2. **Component Monitoring**: Real-time visibility into all daemon components (gateway, channels, etc.)
|
||||
3. **Debug Information**: Detailed health snapshots, retry attempts, and error history
|
||||
4. **Manual Override**: Full control over daemon lifecycle with proper state management
|
||||
|
||||
### Error Handling
|
||||
|
||||
1. **Graceful Degradation**: System works properly in non-Tauri environments
|
||||
2. **Timeout Management**: 30-second timeout detection with automatic recovery attempts
|
||||
3. **User Guidance**: Clear error messages with troubleshooting suggestions
|
||||
4. **Recovery Actions**: Manual restart options when automatic recovery fails
|
||||
|
||||
## Technical Benefits
|
||||
|
||||
### Performance
|
||||
|
||||
- **Efficient Updates**: Debounced Redux updates prevent excessive re-renders
|
||||
- **Memory Management**: Proper cleanup of timeouts and event listeners
|
||||
- **Background Optimization**: Minimal resource usage when app backgrounded
|
||||
|
||||
### Reliability
|
||||
|
||||
- **Timeout Handling**: Robust detection of daemon disconnection
|
||||
- **Exponential Backoff**: Smart retry logic prevents resource exhaustion
|
||||
- **State Consistency**: Coordinated daemon and socket connection states
|
||||
- **Error Recovery**: Automatic recovery from transient failures
|
||||
|
||||
### Maintainability
|
||||
|
||||
- **TypeScript**: Full type safety throughout the implementation
|
||||
- **Modular Design**: Clear separation between state, services, and UI components
|
||||
- **Existing Patterns**: Follows established Redux and component patterns
|
||||
- **Testing**: Comprehensive error handling and edge case management
|
||||
|
||||
## Configuration
|
||||
|
||||
### Auto-Start Behavior
|
||||
|
||||
Users can control daemon auto-start behavior through:
|
||||
|
||||
1. **UI Toggle**: Available in both health panel and settings console
|
||||
2. **Persistence**: Preference stored in Redux with persistence
|
||||
3. **Default**: Auto-start enabled by default for better UX
|
||||
|
||||
### Health Monitoring
|
||||
|
||||
- **Event Frequency**: Rust daemon emits health every 5 seconds
|
||||
- **Timeout Duration**: 30 seconds without health events = disconnected
|
||||
- **Retry Logic**: Maximum 5 attempts with exponential backoff
|
||||
- **Component Tracking**: Gateway, channels, heartbeat, scheduler components
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
|
||||
1. **Health History**: Track daemon health over time with charts/graphs
|
||||
2. **Performance Metrics**: CPU/memory usage from daemon components
|
||||
3. **Log Integration**: Show daemon logs directly in health panel
|
||||
4. **Mobile Optimization**: Enhanced mobile-specific daemon management
|
||||
5. **Notification System**: Push notifications for critical daemon events
|
||||
|
||||
### Extensibility
|
||||
|
||||
The architecture supports easy extension for:
|
||||
|
||||
- Additional daemon components
|
||||
- Custom health check logic
|
||||
- Third-party integrations
|
||||
- Advanced monitoring features
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Daemon Lifecycle Management System provides a complete bridge between the sophisticated Rust daemon infrastructure and the React frontend, delivering excellent user experience while maintaining the technical robustness required for a production application.
|
||||
|
||||
The implementation follows established patterns, provides comprehensive error handling, and creates a foundation for future daemon-related features while ensuring the system remains reliable and user-friendly.
|
||||
@@ -1,627 +0,0 @@
|
||||
# Gmail Skill Integration — Design Reference
|
||||
|
||||
**Scope:** Native Gmail skill — OAuth2 connect flow, tunnel-based callback, token storage,
|
||||
initial inbox sync, and `send_email` tool dispatch.
|
||||
|
||||
**Status:** Design reference / implementation guide.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
This document describes the full lifecycle of connecting a Gmail skill to ZeroClaw.
|
||||
It covers four concerns in order:
|
||||
|
||||
1. **OAuth2 connect** — how the user authorises Gmail access
|
||||
2. **Tunnel callback** — how the authorization code reaches the backend through the tunnel
|
||||
3. **Token storage** — how credentials are encrypted and persisted in the auth profile store
|
||||
4. **Email sync + send** — how the initial inbox is fetched and how sending works
|
||||
|
||||
The skill is declared with the following manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gmail",
|
||||
"name": "Gmail",
|
||||
"version": "1.0.0",
|
||||
"description": "Gmail integration via Google API — comprehensive email management with OAuth2 authentication, send/receive, labels, search, and attachments.",
|
||||
"auto_start": false,
|
||||
"platforms": ["windows", "macos", "linux", "android", "ios"],
|
||||
"setup": {
|
||||
"required": true,
|
||||
"label": "Connect Gmail",
|
||||
"oauth": {
|
||||
"provider": "gmail",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/gmail.labels"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The equivalent `SKILL.toml` that ZeroClaw loads from
|
||||
`~/.zeroclaw/workspace/skills/gmail/SKILL.toml` is shown in §2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Skill Manifest (`SKILL.toml`)
|
||||
|
||||
Skills in ZeroClaw are loaded by `src/skills/mod.rs`. The loader reads a `SKILL.toml`
|
||||
(or `SKILL.md`) file from `~/.zeroclaw/workspace/skills/<name>/`. The Rust struct it
|
||||
populates is `Skill` / `SkillManifest` / `SkillTool`.
|
||||
|
||||
`SKILL.toml` for Gmail:
|
||||
|
||||
```toml
|
||||
[skill]
|
||||
name = "gmail"
|
||||
version = "1.0.0"
|
||||
description = "Gmail integration via Google API — send/receive, labels, search, attachments."
|
||||
author = "zeroclaw"
|
||||
tags = ["email", "google", "productivity"]
|
||||
|
||||
# OAuth setup block — interpreted by the skill connect subsystem (see §3).
|
||||
[skill.setup]
|
||||
required = true
|
||||
label = "Connect Gmail"
|
||||
|
||||
[skill.setup.oauth]
|
||||
provider = "gmail"
|
||||
scopes = [
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/gmail.labels",
|
||||
]
|
||||
|
||||
# Tools exposed to the agent after connection.
|
||||
|
||||
[[tools]]
|
||||
name = "gmail_send_email"
|
||||
description = "Send an email via Gmail. Requires a connected Gmail account."
|
||||
kind = "http"
|
||||
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
||||
|
||||
[[tools]]
|
||||
name = "gmail_list_messages"
|
||||
description = "List messages in the Gmail inbox. Supports query filters."
|
||||
kind = "http"
|
||||
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages"
|
||||
|
||||
[[tools]]
|
||||
name = "gmail_get_message"
|
||||
description = "Fetch the full content of a single Gmail message by ID."
|
||||
kind = "http"
|
||||
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}"
|
||||
|
||||
[[tools]]
|
||||
name = "gmail_modify_labels"
|
||||
description = "Add or remove labels on a Gmail message."
|
||||
kind = "http"
|
||||
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}/modify"
|
||||
```
|
||||
|
||||
At runtime, `load_skills_with_config()` (`src/skills/mod.rs:78`) reads these tools into
|
||||
`Vec<SkillTool>` and injects them into the agent system prompt. The `kind = "http"` tools
|
||||
are dispatched via `HttpRequestTool` (`src/tools/http_request.rs`) with the stored
|
||||
Bearer token injected as the `Authorization` header (see §5).
|
||||
|
||||
---
|
||||
|
||||
## 3. OAuth2 Connect Flow
|
||||
|
||||
### 3.1 What the user triggers
|
||||
|
||||
When the user asks to connect Gmail (via the frontend or a CLI command), the backend:
|
||||
|
||||
1. Generates a **PKCE pair** (code verifier + SHA-256 challenge) — following the same
|
||||
pattern as `src/auth/openai_oauth.rs:generate_pkce_state()`.
|
||||
2. Generates a random CSRF `state` token (24 bytes, base64url).
|
||||
3. Constructs the Google OAuth2 authorisation URL:
|
||||
|
||||
```
|
||||
https://accounts.google.com/o/oauth2/v2/auth
|
||||
?response_type=code
|
||||
&client_id=<GOOGLE_CLIENT_ID>
|
||||
&redirect_uri=<TUNNEL_PUBLIC_URL>/auth/callback/gmail
|
||||
&scope=https://www.googleapis.com/auth/gmail.send
|
||||
https://www.googleapis.com/auth/gmail.modify
|
||||
https://www.googleapis.com/auth/gmail.labels
|
||||
&code_challenge=<SHA256_CHALLENGE>
|
||||
&code_challenge_method=S256
|
||||
&state=<RANDOM_STATE>
|
||||
&access_type=offline
|
||||
&prompt=consent
|
||||
```
|
||||
|
||||
`access_type=offline` is required to receive a `refresh_token` from Google.
|
||||
`prompt=consent` ensures the refresh token is issued on every connect, not just the first.
|
||||
|
||||
4. The URL is sent to the frontend. The frontend opens it in a browser or WebView.
|
||||
|
||||
### 3.2 Where the redirect URI points
|
||||
|
||||
The `redirect_uri` is the **tunnel public URL** with the path `/auth/callback/gmail`.
|
||||
|
||||
ZeroClaw's tunnel system (`src/tunnel/mod.rs`) exposes the local gateway port via one of:
|
||||
|
||||
| Provider | How public URL is obtained |
|
||||
| ------------ | ---------------------------------------------------------------- |
|
||||
| `cloudflare` | `cloudflared tunnel` process stdout — `src/tunnel/cloudflare.rs` |
|
||||
| `ngrok` | ngrok local API `GET /api/tunnels` — `src/tunnel/ngrok.rs` |
|
||||
| `tailscale` | `tailscale funnel` + hostname — `src/tunnel/tailscale.rs` |
|
||||
| `custom` | user-supplied URL or stdout pattern — `src/tunnel/custom.rs` |
|
||||
|
||||
The tunnel's `start(local_host, local_port)` method returns the public URL string.
|
||||
This URL is what gets used as the `redirect_uri`.
|
||||
|
||||
Example with Cloudflare Tunnel:
|
||||
|
||||
```
|
||||
https://your-subdomain.trycloudflare.com/auth/callback/gmail
|
||||
```
|
||||
|
||||
Google's OAuth2 server sends the browser to this URL after the user grants consent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Receiving the Authorization Code via the Tunnel
|
||||
|
||||
### 4.1 The callback endpoint
|
||||
|
||||
The gateway (`src/gateway/mod.rs`) runs as an Axum HTTP server. A new route needs to be
|
||||
registered to receive the OAuth2 callback:
|
||||
|
||||
```
|
||||
GET /auth/callback/gmail?code=<AUTH_CODE>&state=<STATE>
|
||||
```
|
||||
|
||||
This route handler must:
|
||||
|
||||
1. **Validate the `state` parameter** against the CSRF token stored in memory when the
|
||||
flow was initiated. Reject mismatches with `400 Bad Request`.
|
||||
2. **Extract the `code`** query parameter.
|
||||
3. **Exchange the code** for tokens by calling Google's token endpoint:
|
||||
|
||||
```
|
||||
POST https://oauth2.googleapis.com/token
|
||||
grant_type=authorization_code
|
||||
code=<AUTH_CODE>
|
||||
client_id=<GOOGLE_CLIENT_ID>
|
||||
client_secret=<GOOGLE_CLIENT_SECRET>
|
||||
redirect_uri=<TUNNEL_PUBLIC_URL>/auth/callback/gmail
|
||||
code_verifier=<PKCE_VERIFIER>
|
||||
```
|
||||
|
||||
The exchange pattern mirrors `src/auth/openai_oauth.rs:exchange_code_for_tokens()`,
|
||||
which posts a form body and parses the JSON response into `TokenSet`.
|
||||
|
||||
Google returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "ya29.a0...",
|
||||
"expires_in": 3599,
|
||||
"refresh_token": "1//0g...",
|
||||
"scope": "https://www.googleapis.com/auth/gmail.send ...",
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
4. The handler stores the `TokenSet` (see §4.2) and returns a success response to the
|
||||
browser (an HTML page or redirect to the frontend app confirming the connection).
|
||||
|
||||
### 4.2 The in-flight CSRF / PKCE state
|
||||
|
||||
Before redirecting the user to Google, the pending PKCE state and CSRF token must be
|
||||
stored so the callback handler can look them up. The right storage is the skill's dedicated
|
||||
in-memory or short-lived file-backed state, keyed by the `state` value:
|
||||
|
||||
```
|
||||
pending_oauth_states: HashMap<state_token, (PkceState, initiated_at)>
|
||||
```
|
||||
|
||||
Entries expire after a fixed window (e.g. 10 minutes) to prevent orphaned state
|
||||
accumulation. This follows the same pattern ZeroClaw already uses for pairing nonces
|
||||
in `src/security/pairing.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Token Storage
|
||||
|
||||
After a successful token exchange, the tokens are persisted using the `AuthService`
|
||||
and `AuthProfilesStore` from `src/auth/`.
|
||||
|
||||
### 5.1 Storing the token set
|
||||
|
||||
```rust
|
||||
// Pseudocode — mirrors src/auth/mod.rs AuthService::store_openai_tokens()
|
||||
let token_set = TokenSet {
|
||||
access_token: response.access_token,
|
||||
refresh_token: response.refresh_token,
|
||||
id_token: None,
|
||||
expires_at: Some(Utc::now() + Duration::seconds(response.expires_in)),
|
||||
token_type: Some("Bearer".into()),
|
||||
scope: Some(scopes.join(" ")),
|
||||
};
|
||||
|
||||
auth_service
|
||||
.store_oauth_tokens("gmail", "default", token_set, None, true)
|
||||
.await?;
|
||||
```
|
||||
|
||||
`store_oauth_tokens` calls `AuthProfilesStore::upsert_profile()`, which:
|
||||
|
||||
1. Creates or updates an `AuthProfile` with `kind = AuthProfileKind::OAuth`.
|
||||
2. Serialises the profile data to `~/.zeroclaw/auth-profiles.json` (file-locked via
|
||||
`auth-profiles.lock` with a 10-second timeout — `src/auth/profiles.rs:16–17`).
|
||||
|
||||
### 5.2 How tokens are encrypted at rest
|
||||
|
||||
Before writing to disk, every secret field passes through `SecretStore::encrypt()`
|
||||
(`src/security/secrets.rs`). The encryption scheme is:
|
||||
|
||||
- **Algorithm**: ChaCha20-Poly1305 AEAD (256-bit key)
|
||||
- **Key file**: `~/.zeroclaw/.secret_key` (permissions 0600, created on first use)
|
||||
- **Format on disk**: `enc2:<hex(12-byte-nonce ‖ ciphertext ‖ 16-byte-Poly1305-tag)>`
|
||||
- **Config**: encryption is enabled by default; disable with `secrets.encrypt = false`
|
||||
|
||||
The stored profile entry looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "gmail:default",
|
||||
"provider": "gmail",
|
||||
"profile_name": "default",
|
||||
"kind": "oauth",
|
||||
"token_set": {
|
||||
"access_token": "enc2:a1b2c3...",
|
||||
"refresh_token": "enc2:d4e5f6...",
|
||||
"expires_at": "2026-03-09T12:00:00Z",
|
||||
"token_type": "Bearer",
|
||||
"scope": "https://www.googleapis.com/auth/gmail.send ..."
|
||||
},
|
||||
"created_at": "2026-03-09T11:00:00Z",
|
||||
"updated_at": "2026-03-09T11:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Token refresh
|
||||
|
||||
Google access tokens expire after 3600 seconds. Before any Gmail API call, the skill
|
||||
must check `token_set.is_expiring_within(Duration::from_secs(90))` (the same 90-second
|
||||
skew used for OpenAI tokens in `src/auth/mod.rs:160`). If expiring:
|
||||
|
||||
```
|
||||
POST https://oauth2.googleapis.com/token
|
||||
grant_type=refresh_token
|
||||
refresh_token=<stored_refresh_token>
|
||||
client_id=<GOOGLE_CLIENT_ID>
|
||||
client_secret=<GOOGLE_CLIENT_SECRET>
|
||||
```
|
||||
|
||||
The new `access_token` (and new `refresh_token` if Google rotates it) is written back
|
||||
to the auth profile via `AuthProfilesStore::update_profile()`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Initial Email Sync (First 100 Emails)
|
||||
|
||||
Once connected, the skill performs a one-time initial sync. This is triggered
|
||||
immediately after a successful token exchange and runs as a background task.
|
||||
|
||||
### 6.1 Fetch message IDs
|
||||
|
||||
```
|
||||
GET https://gmail.googleapis.com/gmail/v1/users/me/messages
|
||||
?maxResults=100
|
||||
&labelIds=INBOX
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Returns up to 100 message descriptors: `[{ "id": "...", "threadId": "..." }, ...]`.
|
||||
|
||||
### 6.2 Fetch full message details
|
||||
|
||||
For each message ID, fetch the full message:
|
||||
|
||||
```
|
||||
GET https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}
|
||||
?format=full
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
The response contains headers (From, To, Subject, Date), a snippet, and the body parts.
|
||||
|
||||
In practice, to avoid 100 sequential requests, use the Gmail batch API:
|
||||
|
||||
```
|
||||
POST https://www.googleapis.com/batch/gmail/v1
|
||||
Content-Type: multipart/mixed; boundary="batch_boundary"
|
||||
|
||||
--batch_boundary
|
||||
Content-Type: application/http
|
||||
GET /gmail/v1/users/me/messages/{id1}?format=full
|
||||
...
|
||||
```
|
||||
|
||||
### 6.3 Storing emails in ZeroClaw memory
|
||||
|
||||
Each email is stored via `memory_store` (`src/tools/memory_store.rs`) with:
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | ----------------------------------------------------------------- |
|
||||
| `key` | `gmail_msg_<message_id>` |
|
||||
| `content` | Formatted string: `From: ... Subject: ... Date: ... Snippet: ...` |
|
||||
| `category` | `MemoryCategory::Custom("gmail")` |
|
||||
|
||||
This makes every synced email searchable via `memory_recall` with queries like
|
||||
`"gmail from:zeroclaw@example.com"`.
|
||||
|
||||
For larger body content that exceeds a single memory entry, store the full body as a
|
||||
separate entry keyed `gmail_body_<message_id>` and the summary/metadata as
|
||||
`gmail_msg_<message_id>`.
|
||||
|
||||
Example stored entries after sync:
|
||||
|
||||
```
|
||||
key: gmail_msg_18de7f2a1b3c4e5d
|
||||
content: From: sender@example.com
|
||||
To: me@gmail.com
|
||||
Subject: Project update
|
||||
Date: 2026-03-08T14:32:00Z
|
||||
Snippet: "Here is the latest update on the project..."
|
||||
Labels: INBOX, UNREAD
|
||||
category: gmail
|
||||
|
||||
key: gmail_body_18de7f2a1b3c4e5d
|
||||
content: (full decoded email body text)
|
||||
category: gmail
|
||||
```
|
||||
|
||||
### 6.4 Sync state tracking
|
||||
|
||||
Store the highest-known history ID after sync so incremental sync can pick up from
|
||||
where it left off:
|
||||
|
||||
```
|
||||
key: gmail_sync_history_id
|
||||
content: 12345678
|
||||
category: core
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. The `gmail_send_email` Tool
|
||||
|
||||
### 7.1 Tool definition in `SKILL.toml`
|
||||
|
||||
The `[[tools]]` entry from §2:
|
||||
|
||||
```toml
|
||||
[[tools]]
|
||||
name = "gmail_send_email"
|
||||
description = "Send an email via Gmail. Requires a connected Gmail account."
|
||||
kind = "http"
|
||||
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
|
||||
```
|
||||
|
||||
### 7.2 How the agent calls it
|
||||
|
||||
The LLM emits a tool call:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "gmail_send_email",
|
||||
"arguments": {
|
||||
"to": "recipient@example.com",
|
||||
"subject": "Hello from ZeroClaw",
|
||||
"body": "This is a test email sent by the agent."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Tool dispatch path
|
||||
|
||||
```
|
||||
Agent loop (src/agent/loop_.rs)
|
||||
└── dispatches to registered tool by name
|
||||
└── HttpRequestTool::execute(args) [src/tools/http_request.rs]
|
||||
├── Retrieve gmail access_token from AuthService
|
||||
│ └── auth_service.get_provider_bearer_token("gmail", None)
|
||||
│ └── decrypts enc2: value via SecretStore::decrypt()
|
||||
├── Build RFC 2822 message and base64url-encode it
|
||||
├── POST https://gmail.googleapis.com/gmail/v1/users/me/messages/send
|
||||
│ Headers:
|
||||
│ Authorization: Bearer <decrypted_access_token>
|
||||
│ Content-Type: application/json
|
||||
│ Body:
|
||||
│ { "raw": "<base64url_encoded_rfc2822_message>" }
|
||||
└── Return ToolResult { success, output: "Message sent. ID: <id>" }
|
||||
```
|
||||
|
||||
### 7.4 Building the RFC 2822 message
|
||||
|
||||
Gmail's send endpoint requires the email encoded as an RFC 2822 message, then base64url
|
||||
encoded. The structure:
|
||||
|
||||
```
|
||||
From: me@gmail.com
|
||||
To: recipient@example.com
|
||||
Subject: Hello from ZeroClaw
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
MIME-Version: 1.0
|
||||
|
||||
This is a test email sent by the agent.
|
||||
```
|
||||
|
||||
Then the entire string is base64url-encoded (no padding) and placed in the `raw` field:
|
||||
|
||||
```json
|
||||
{ "raw": "RnJvbTogbWVAZ21haWwuY29tCi..." }
|
||||
```
|
||||
|
||||
### 7.5 Security enforcement
|
||||
|
||||
Before executing any outbound HTTP call, the `SecurityPolicy` is consulted:
|
||||
|
||||
```rust
|
||||
// src/security/policy.rs
|
||||
security.enforce_tool_operation(ToolOperation::Act, "gmail_send_email")
|
||||
```
|
||||
|
||||
`ToolOperation::Act` is the category for write/side-effect operations. If the agent is
|
||||
running in `ReadOnly` or `Supervised` mode, this call fails with an explicit error before
|
||||
any network request is made.
|
||||
|
||||
The `HttpRequestTool` additionally enforces the `http.allowed_domains` allowlist. For
|
||||
Gmail tools, `googleapis.com` must be present in that list:
|
||||
|
||||
```toml
|
||||
# config.toml
|
||||
[http]
|
||||
enabled = true
|
||||
allowed_domains = ["googleapis.com"]
|
||||
```
|
||||
|
||||
### 7.6 Reply-to and threading
|
||||
|
||||
To send a reply within an existing thread, add the Gmail `threadId` to the request body:
|
||||
|
||||
```json
|
||||
{ "raw": "<base64url_message_with_In-Reply-To_header>", "threadId": "18de7f2a1b3c4e5d" }
|
||||
```
|
||||
|
||||
The RFC 2822 message must include `In-Reply-To: <original_message_id>` and
|
||||
`References: <original_message_id>` headers for proper threading.
|
||||
|
||||
---
|
||||
|
||||
## 8. Full End-to-End Sequence
|
||||
|
||||
```
|
||||
Frontend / User
|
||||
│
|
||||
│ 1. "Connect Gmail"
|
||||
▼
|
||||
ZeroClaw Backend
|
||||
│ 2. Generate PKCE + CSRF state
|
||||
│ 3. Build Google OAuth2 authorize URL with tunnel redirect_uri
|
||||
│ 4. Return URL to frontend
|
||||
│
|
||||
▼
|
||||
Browser (user)
|
||||
│ 5. User visits URL, grants consent in Google
|
||||
│
|
||||
▼
|
||||
Google OAuth2
|
||||
│ 6. Browser redirected to <TUNNEL_PUBLIC_URL>/auth/callback/gmail?code=XXX&state=YYY
|
||||
│
|
||||
▼
|
||||
Tunnel (cloudflare / ngrok / tailscale)
|
||||
│ 7. Proxies HTTPS request to local gateway port
|
||||
│
|
||||
▼
|
||||
Gateway (src/gateway/mod.rs) — GET /auth/callback/gmail
|
||||
│ 8. Validate CSRF state
|
||||
│ 9. Exchange code → TokenSet via POST https://oauth2.googleapis.com/token
|
||||
│ 10. Store TokenSet in AuthProfilesStore (encrypted, "gmail:default")
|
||||
│ 11. Return success page/redirect to frontend
|
||||
│
|
||||
▼
|
||||
Background task — initial sync
|
||||
│ 12. GET /gmail/v1/users/me/messages?maxResults=100&labelIds=INBOX
|
||||
│ 13. Batch-fetch 100 full messages
|
||||
│ 14. memory_store each email under key=gmail_msg_<id>, category=gmail
|
||||
│ 15. memory_store gmail_sync_history_id = <latest_historyId>
|
||||
│
|
||||
▼
|
||||
Agent (subsequent interactions)
|
||||
│ 16. LLM generates tool call: gmail_send_email { to, subject, body }
|
||||
│ 17. Agent loop dispatches to HttpRequestTool
|
||||
│ 18. HttpRequestTool retrieves + decrypts access_token from AuthService
|
||||
│ 19. Refreshes token if expiring within 90 seconds
|
||||
│ 20. POST https://gmail.googleapis.com/gmail/v1/users/me/messages/send
|
||||
│ 21. Returns ToolResult to agent
|
||||
▼
|
||||
Done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Configuration Reference
|
||||
|
||||
All Gmail skill config lives in `config.toml`:
|
||||
|
||||
```toml
|
||||
# Enable HTTP request tool with Google API access
|
||||
[http]
|
||||
enabled = true
|
||||
allowed_domains = ["googleapis.com", "oauth2.googleapis.com"]
|
||||
timeout_secs = 30
|
||||
max_response_size = 524288 # 512KB — enough for email batch responses
|
||||
|
||||
# Tunnel for OAuth callback (pick one)
|
||||
[tunnel]
|
||||
provider = "cloudflare"
|
||||
[tunnel.cloudflare]
|
||||
token = "your-cloudflare-tunnel-token"
|
||||
|
||||
# Or ngrok:
|
||||
# [tunnel]
|
||||
# provider = "ngrok"
|
||||
# [tunnel.ngrok]
|
||||
# auth_token = "your-ngrok-token"
|
||||
|
||||
# Google OAuth2 app credentials (stored encrypted)
|
||||
# Set via environment or onboard wizard — never commit raw values
|
||||
[integrations.gmail]
|
||||
client_id = "enc2:..."
|
||||
client_secret = "enc2:..."
|
||||
```
|
||||
|
||||
The Google credentials (`client_id`, `client_secret`) are encrypted by `SecretStore`
|
||||
before being written to `config.toml` — the same ChaCha20-Poly1305 scheme used for all
|
||||
secrets (`src/security/secrets.rs`).
|
||||
|
||||
---
|
||||
|
||||
## 10. Key Source Files
|
||||
|
||||
| File | Role |
|
||||
| ---------------------------- | ----------------------------------------------------------------- |
|
||||
| `src/skills/mod.rs` | Skill loading, `SkillTool` struct, `load_skills_with_config()` |
|
||||
| `src/auth/mod.rs` | `AuthService` — store/retrieve/refresh OAuth tokens |
|
||||
| `src/auth/profiles.rs` | `AuthProfile`, `TokenSet`, `AuthProfilesStore` — JSON persistence |
|
||||
| `src/auth/openai_oauth.rs` | PKCE generation, code exchange, refresh — reference pattern |
|
||||
| `src/security/secrets.rs` | `SecretStore` — ChaCha20-Poly1305 encrypt/decrypt |
|
||||
| `src/tunnel/mod.rs` | `Tunnel` trait + factory — public URL for OAuth redirect |
|
||||
| `src/tunnel/cloudflare.rs` | Cloudflare Tunnel implementation |
|
||||
| `src/tunnel/ngrok.rs` | ngrok implementation |
|
||||
| `src/gateway/mod.rs` | Axum HTTP gateway — where `/auth/callback/gmail` is registered |
|
||||
| `src/tools/http_request.rs` | `HttpRequestTool` — dispatches Gmail API calls |
|
||||
| `src/tools/memory_store.rs` | `MemoryStoreTool` — stores synced emails |
|
||||
| `src/tools/memory_recall.rs` | `MemoryRecallTool` — searches synced emails |
|
||||
| `src/security/policy.rs` | `SecurityPolicy` — enforces `ToolOperation::Act` guards |
|
||||
| `docs/config-reference.md` | Full config schema including `[http]`, `[tunnel]` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Security Notes
|
||||
|
||||
- The OAuth2 `redirect_uri` **must be the tunnel URL**. It cannot be `localhost` in a
|
||||
remote/mobile scenario. Google validates the exact URI registered in the Google Cloud
|
||||
Console; register it as `https://<your-tunnel-domain>/auth/callback/gmail`.
|
||||
- The CSRF `state` token must be validated on every callback. Reject mismatched or
|
||||
missing state with `400 Bad Request` before touching any tokens.
|
||||
- The PKCE verifier must be destroyed after a successful or failed exchange — never
|
||||
persist it beyond the in-flight flow.
|
||||
- `client_secret` must never appear in plaintext in `config.toml`, logs, or agent
|
||||
tool output. Encrypt it via the secret store and redact it in observability output.
|
||||
- `googleapis.com` must be explicitly present in `http.allowed_domains` — the
|
||||
`HttpRequestTool` enforces this allowlist before every request
|
||||
(`src/tools/http_request.rs`).
|
||||
- Token refresh runs under the same per-profile Tokio mutex used for OpenAI tokens
|
||||
(`src/auth/mod.rs:287`) to prevent double-refresh races.
|
||||
@@ -1,530 +0,0 @@
|
||||
# AlphaHuman Teams API Reference
|
||||
|
||||
Complete reference for all teams-related API endpoints in the AlphaHuman platform.
|
||||
|
||||
**Base URL**: `https://api.alphahuman.xyz`
|
||||
**Authentication**: Bearer JWT token required for all endpoints
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
---
|
||||
|
||||
## Core Team Management
|
||||
|
||||
### 1. Create Team
|
||||
|
||||
**POST** `/teams`
|
||||
|
||||
Creates a new team with optional encryption.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{ "name": "string (required)", "magicWord": "string (optional)" }
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"slug": "string",
|
||||
"magicWord": "string | null",
|
||||
"createdBy": "string",
|
||||
"isPersonal": "boolean",
|
||||
"subscription": {
|
||||
"hasActiveSubscription": "boolean",
|
||||
"plan": "FREE|BASIC|PRO",
|
||||
"planExpiry": "date-time | null",
|
||||
"stripeCustomerId": "string | null"
|
||||
},
|
||||
"usage": {
|
||||
"weeklyBudgetUsd": "number",
|
||||
"spentThisWeekUsd": "number",
|
||||
"weekStartDate": "date-time"
|
||||
},
|
||||
"inviteCode": "string | null",
|
||||
"maxMembers": "number",
|
||||
"createdAt": "date-time",
|
||||
"updatedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Team created successfully
|
||||
- `401` - Unauthorized
|
||||
|
||||
---
|
||||
|
||||
### 2. List Teams
|
||||
|
||||
**GET** `/teams`
|
||||
|
||||
Retrieves all teams the authenticated user belongs to.
|
||||
|
||||
**Response Schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"team": {
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"slug": "string",
|
||||
"isPersonal": "boolean",
|
||||
"subscription": { "hasActiveSubscription": "boolean", "plan": "FREE|BASIC|PRO" }
|
||||
},
|
||||
"role": "admin|billing_manager|member"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Success
|
||||
- `401` - Unauthorized
|
||||
|
||||
---
|
||||
|
||||
### 3. Get Team Details
|
||||
|
||||
**GET** `/teams/{teamId}`
|
||||
|
||||
Retrieves detailed information for a specific team.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Response Schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"slug": "string",
|
||||
"magicWord": "string | null",
|
||||
"createdBy": "string",
|
||||
"isPersonal": "boolean",
|
||||
"subscription": {
|
||||
"hasActiveSubscription": "boolean",
|
||||
"plan": "FREE|BASIC|PRO",
|
||||
"planExpiry": "date-time | null",
|
||||
"stripeCustomerId": "string | null"
|
||||
},
|
||||
"usage": {
|
||||
"weeklyBudgetUsd": "number",
|
||||
"spentThisWeekUsd": "number",
|
||||
"weekStartDate": "date-time"
|
||||
},
|
||||
"inviteCode": "string | null",
|
||||
"maxMembers": "number",
|
||||
"createdAt": "date-time",
|
||||
"updatedAt": "date-time"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Success
|
||||
- `403` - Not a member of the team
|
||||
- `404` - Team not found
|
||||
|
||||
---
|
||||
|
||||
### 4. Update Team Settings
|
||||
|
||||
**PUT** `/teams/{teamId}`
|
||||
|
||||
Updates team settings. **Admin only**.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{ "name": "string (optional)", "maxMembers": "number (optional)" }
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Team updated successfully
|
||||
- `403` - Only admins can update team settings
|
||||
|
||||
---
|
||||
|
||||
### 5. Delete Team
|
||||
|
||||
**DELETE** `/teams/{teamId}`
|
||||
|
||||
Deletes a team. **Admin only, non-personal teams only**.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Team deleted successfully
|
||||
- `400` - Cannot delete a personal team
|
||||
- `403` - Only admins can delete a team
|
||||
|
||||
---
|
||||
|
||||
### 6. Switch Active Team
|
||||
|
||||
**POST** `/teams/{teamId}/switch`
|
||||
|
||||
Changes the user's active team.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Successfully switched active team
|
||||
- `403` - Not a member of the specified team
|
||||
|
||||
---
|
||||
|
||||
## Team Member Management
|
||||
|
||||
### 7. List Team Members
|
||||
|
||||
**GET** `/teams/{teamId}/members`
|
||||
|
||||
Lists all members of a team.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Response Schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [{ "user": "string", "role": "admin|billing_manager|member", "joinedAt": "date-time" }]
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Success
|
||||
- `403` - Not a member of this team
|
||||
|
||||
---
|
||||
|
||||
### 8. Remove Team Member
|
||||
|
||||
**DELETE** `/teams/{teamId}/members/{userId}`
|
||||
|
||||
Removes a member from the team. **Admin only**.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
- `userId` (path, required) - User identifier to remove
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Member removed
|
||||
- `403` - Only admins can remove members
|
||||
|
||||
---
|
||||
|
||||
### 9. Change Member Role
|
||||
|
||||
**PUT** `/teams/{teamId}/members/{userId}/role`
|
||||
|
||||
Changes a member's role within the team. **Admin only**.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
- `userId` (path, required) - User identifier
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{ "role": "admin|billing_manager|member" }
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Role updated
|
||||
- `403` - Only admins can change member roles
|
||||
|
||||
---
|
||||
|
||||
### 10. Leave Team
|
||||
|
||||
**POST** `/teams/{teamId}/leave`
|
||||
|
||||
Allows a user to leave a team.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Successfully left the team
|
||||
- `400` - Cannot leave as the only admin
|
||||
|
||||
---
|
||||
|
||||
## Team Invite Management
|
||||
|
||||
### 11. Create Team Invite
|
||||
|
||||
**POST** `/teams/{teamId}/invites`
|
||||
|
||||
Creates a new invite code for the team.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Request Body (Optional):**
|
||||
|
||||
```json
|
||||
{ "maxUses": "number (default: 1)", "expiresInDays": "number (default: 7)" }
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "code": "string (e.g., T-1A2B3C4D5E6F)", "expiresAt": "date-time", "maxUses": "number" }
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Invite created successfully
|
||||
- `403` - Not a team member
|
||||
|
||||
---
|
||||
|
||||
### 12. List Team Invites
|
||||
|
||||
**GET** `/teams/{teamId}/invites`
|
||||
|
||||
Lists all invites for a team.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Success
|
||||
- `403` - User is not a member of the team
|
||||
|
||||
---
|
||||
|
||||
### 13. Revoke Team Invite
|
||||
|
||||
**DELETE** `/teams/{teamId}/invites/{inviteId}`
|
||||
|
||||
Revokes a team invite. **Admin or invite creator only**.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
- `inviteId` (path, required) - Invite identifier
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Invite successfully revoked
|
||||
- `403` - Only admins or invite creator can revoke
|
||||
- `404` - Invite does not exist
|
||||
|
||||
---
|
||||
|
||||
### 14. Join Team
|
||||
|
||||
**POST** `/teams/join`
|
||||
|
||||
Joins a team using an invite code.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{ "code": "string (required, e.g., T-1A2B3C4D5E6F)" }
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { "team": "string", "membership": "string" } }
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Joined the team successfully
|
||||
- `400` - Invite expired, max uses reached, or already a team member
|
||||
- `404` - Invite code not found
|
||||
|
||||
---
|
||||
|
||||
## Team Billing Management
|
||||
|
||||
### 15. Purchase Team Subscription
|
||||
|
||||
**POST** `/teams/{teamId}/billing/purchase`
|
||||
|
||||
Purchases a subscription plan for the team. **Admin or billing manager only**.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"plan": "BASIC_MONTHLY|BASIC_YEARLY|PRO_MONTHLY|PRO_YEARLY",
|
||||
"successUrl": "string (optional)",
|
||||
"cancelUrl": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Checkout session created successfully
|
||||
- `403` - Only admins or billing managers can purchase plans
|
||||
|
||||
---
|
||||
|
||||
### 16. Get Team Subscription Plan
|
||||
|
||||
**GET** `/teams/{teamId}/billing/plan`
|
||||
|
||||
Retrieves the team's current subscription information.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Response Schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"plan": "FREE|BASIC|PRO",
|
||||
"hasActiveSubscription": "boolean",
|
||||
"planExpiry": "date-time | null"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Success
|
||||
- `401` - Unauthorized
|
||||
|
||||
---
|
||||
|
||||
### 17. Create Billing Portal Session
|
||||
|
||||
**POST** `/teams/{teamId}/billing/portal`
|
||||
|
||||
Creates a Stripe billing portal session for subscription management. **Admin or billing manager only**.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `teamId` (path, required) - Team identifier
|
||||
|
||||
**Request Body (Optional):**
|
||||
|
||||
```json
|
||||
{ "returnUrl": "string" }
|
||||
```
|
||||
|
||||
**Response Schema:**
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { "url": "string" } }
|
||||
```
|
||||
|
||||
**Status Codes:**
|
||||
|
||||
- `200` - Portal session created successfully
|
||||
- `403` - Only admins or billing managers can create portal session
|
||||
|
||||
---
|
||||
|
||||
## Team Roles
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
1. **admin** - Full team management permissions
|
||||
2. **billing_manager** - Billing and subscription management
|
||||
3. **member** - Basic team member access
|
||||
|
||||
### Permission Matrix
|
||||
|
||||
| Action | Admin | Billing Manager | Member |
|
||||
| -------------------- | ----- | --------------- | ------ |
|
||||
| View team details | ✅ | ✅ | ✅ |
|
||||
| Update team settings | ✅ | ❌ | ❌ |
|
||||
| Delete team | ✅ | ❌ | ❌ |
|
||||
| Add/remove members | ✅ | ❌ | ❌ |
|
||||
| Change member roles | ✅ | ❌ | ❌ |
|
||||
| Create invites | ✅ | ✅ | ✅ |
|
||||
| Manage billing | ✅ | ✅ | ❌ |
|
||||
| Leave team | ✅\* | ✅ | ✅ |
|
||||
|
||||
\*Admin cannot leave if they are the only admin
|
||||
|
||||
---
|
||||
|
||||
## Team Plans & Limits
|
||||
|
||||
### Plan Types
|
||||
|
||||
- **FREE** - Basic team functionality
|
||||
- **BASIC** - Enhanced features and limits
|
||||
- **PRO** - Full feature set and highest limits
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
Teams have usage limits tracked through:
|
||||
|
||||
- `weeklyBudgetUsd` - Weekly spending budget
|
||||
- `spentThisWeekUsd` - Current week spending
|
||||
- `weekStartDate` - When the current week started
|
||||
- `maxMembers` - Maximum team size
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Responses
|
||||
|
||||
```json
|
||||
{ "success": false, "error": "Error message description" }
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
All endpoints require a Bearer JWT token:
|
||||
|
||||
```
|
||||
Authorization: Bearer <your-jwt-token>
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Standard API rate limits apply to all endpoints. Refer to main API documentation for specific limits.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 350 KiB |
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ActionableItem, SnoozeOption } from '../../types/intelligence';
|
||||
|
||||
interface ActionableCardProps {
|
||||
item: ActionableItem;
|
||||
onComplete: (item: ActionableItem) => void;
|
||||
onDismiss: (item: ActionableItem) => void;
|
||||
onSnooze: (item: ActionableItem, duration: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SNOOZE_OPTIONS: SnoozeOption[] = [
|
||||
{ label: '1 hour', duration: 60 * 60 * 1000 },
|
||||
{ label: '6 hours', duration: 6 * 60 * 60 * 1000 },
|
||||
{ label: '24 hours', duration: 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
// Portal component for snooze dropdown to escape stacking contexts
|
||||
interface SnoozeDropdownPortalProps {
|
||||
isOpen: boolean;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
onClose: () => void;
|
||||
onSnooze: (duration: number) => void;
|
||||
}
|
||||
|
||||
function SnoozeDropdownPortal({ isOpen, buttonRef, onClose, onSnooze }: SnoozeDropdownPortalProps) {
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculate position based on button position
|
||||
useEffect(() => {
|
||||
if (isOpen && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const dropdownWidth = 120;
|
||||
|
||||
// Position dropdown below and aligned to right edge of button
|
||||
const left = Math.max(8, rect.right - dropdownWidth);
|
||||
const top = rect.bottom + 4;
|
||||
|
||||
setPosition({ top, left });
|
||||
}
|
||||
}, [isOpen, buttonRef]);
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
|
||||
// Don't close if clicking the button or dropdown itself
|
||||
if (
|
||||
buttonRef.current?.contains(target) ||
|
||||
dropdownRef.current?.contains(target) ||
|
||||
target.closest('[data-snooze-dropdown]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Use capture phase to ensure we handle this before other click handlers
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
return () => document.removeEventListener('click', handleClickOutside, true);
|
||||
}, [isOpen, onClose, buttonRef]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
data-snooze-dropdown
|
||||
className="fixed py-1 bg-stone-900 border border-stone-700 rounded-lg shadow-xl min-w-[120px] z-[9999] animate-fade-in"
|
||||
style={{ top: position.top, left: position.left }}>
|
||||
{SNOOZE_OPTIONS.map(option => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => onSnooze(option.duration)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-white hover:bg-stone-800 transition-colors cursor-pointer">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// Source icons for different actionable item types
|
||||
const SOURCE_ICONS = {
|
||||
email: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
calendar: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
telegram: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
),
|
||||
ai_insight: (
|
||||
<svg className="w-4 h-4" 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>
|
||||
),
|
||||
system: (
|
||||
<svg className="w-4 h-4" 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"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
trading: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
security: (
|
||||
<svg className="w-4 h-4" 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>
|
||||
),
|
||||
};
|
||||
|
||||
function formatTimeAgo(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7));
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes} min${minutes === 1 ? '' : 's'} ago`;
|
||||
if (hours < 24) {
|
||||
if (hours === 1) return '1 hour ago';
|
||||
if (hours <= 6) return `${hours} hours ago`;
|
||||
if (hours <= 12) return 'This morning';
|
||||
return 'This afternoon';
|
||||
}
|
||||
if (days === 1) return 'Yesterday';
|
||||
if (days < 7) return `${days} days ago`;
|
||||
if (weeks === 1) return '1 week ago';
|
||||
return `${weeks} weeks ago`;
|
||||
}
|
||||
|
||||
function isNewItem(date: Date): boolean {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
return diff < 5 * 60 * 1000; // Less than 5 minutes old
|
||||
}
|
||||
|
||||
export function ActionableCard({
|
||||
item,
|
||||
onComplete,
|
||||
onDismiss,
|
||||
onSnooze,
|
||||
className = '',
|
||||
}: ActionableCardProps) {
|
||||
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
|
||||
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
||||
const snoozeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleComplete = () => {
|
||||
// Always let the parent handle completion logic
|
||||
// The parent (Intelligence.tsx) ALWAYS opens ChatModal for ALL tick actions
|
||||
onComplete(item);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Always let the parent handle dismiss logic and show confirmation modal
|
||||
// The parent (Intelligence.tsx) always shows confirmation for ALL dismiss actions
|
||||
onDismiss(item);
|
||||
};
|
||||
|
||||
const handleSnooze = (duration: number) => {
|
||||
setIsAnimatingOut(true);
|
||||
setTimeout(() => {
|
||||
onSnooze(item, duration);
|
||||
setShowSnoozeMenu(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Priority styling
|
||||
const priorityClasses = {
|
||||
critical: 'border-coral-500/30 bg-coral-500/5',
|
||||
important: 'border-amber-500/30 bg-amber-500/5',
|
||||
normal: 'border-white/10 bg-white/[0.02]',
|
||||
};
|
||||
|
||||
const priorityDotClasses = {
|
||||
critical: 'bg-coral-400',
|
||||
important: 'bg-amber-400',
|
||||
normal: 'bg-sage-400',
|
||||
};
|
||||
|
||||
const sourceIcon = item.icon || SOURCE_ICONS[item.source];
|
||||
const isNew = isNewItem(item.createdAt);
|
||||
const timeAgo = formatTimeAgo(item.createdAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative group transition-all duration-200 ease-in-out
|
||||
${isAnimatingOut ? 'opacity-0 translate-x-4 scale-95' : 'animate-fade-up'}
|
||||
${className}
|
||||
`}>
|
||||
<div
|
||||
className={`
|
||||
relative p-4 rounded-xl border backdrop-blur-sm transition-all duration-200
|
||||
hover:bg-white/[0.04] hover:border-white/20
|
||||
${priorityClasses[item.priority]}
|
||||
`}>
|
||||
{/* Main content row */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="w-8 h-8 flex items-center justify-center text-white/70 flex-shrink-0 mt-0.5">
|
||||
{sourceIcon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-white leading-snug">{item.title}</h3>
|
||||
{item.description && (
|
||||
<p className="text-xs text-stone-400 mt-1 leading-relaxed">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Complete button */}
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-sage-400 hover:bg-sage-400/10 transition-all duration-150"
|
||||
title="Complete">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-coral-400 hover:bg-coral-400/10 transition-all duration-150"
|
||||
title="Dismiss">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Snooze button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={snoozeButtonRef}
|
||||
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-amber-400 hover:bg-amber-400/10 transition-all duration-150"
|
||||
title="Snooze">
|
||||
<svg
|
||||
className="w-3.5 h-3.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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${priorityDotClasses[item.priority]}`} />
|
||||
<span className="text-xs text-stone-500">{item.sourceLabel || item.source}</span>
|
||||
</div>
|
||||
<span className="text-xs text-stone-600">•</span>
|
||||
<span className="text-xs text-stone-500">{timeAgo}</span>
|
||||
{isNew && (
|
||||
<>
|
||||
<span className="text-xs text-stone-600">•</span>
|
||||
<span className="text-xs bg-sage-500 text-white px-1.5 py-0.5 rounded-sm font-medium">
|
||||
New
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snooze dropdown portal - renders outside of any stacking context */}
|
||||
<SnoozeDropdownPortal
|
||||
isOpen={showSnoozeMenu}
|
||||
buttonRef={snoozeButtonRef}
|
||||
onClose={() => setShowSnoozeMenu(false)}
|
||||
onSnooze={handleSnooze}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { ConfirmationModal as ConfirmationModalType } from '../../types/intelligence';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
modal: ConfirmationModalType;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
if (!modal.isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
modal.onConfirm();
|
||||
onClose();
|
||||
// TODO: Handle dontShowAgain preference storage
|
||||
if (dontShowAgain) {
|
||||
console.log('User chose to not show similar confirmations again');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
modal.onCancel();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 animate-fade-in"
|
||||
onClick={handleCancel}>
|
||||
<div
|
||||
className="bg-stone-900 rounded-2xl max-w-md w-full shadow-large border border-stone-700/50 animate-slide-up"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{modal.destructive && (
|
||||
<div className="w-10 h-10 rounded-full bg-coral-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-coral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-white">{modal.title}</h2>
|
||||
<p className="text-sm text-stone-400 mt-1">{modal.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Don't show again option */}
|
||||
{modal.showDontShowAgain && (
|
||||
<div className="px-6 pb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dontShowAgain}
|
||||
onChange={e => setDontShowAgain(e.target.checked)}
|
||||
className="rounded border-stone-600 bg-stone-800 text-primary-500 focus:ring-primary-500 focus:ring-offset-0"
|
||||
/>
|
||||
Don't show similar suggestions
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 pt-4 border-t border-stone-700/50">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-stone-400 hover:text-white rounded-lg hover:bg-stone-800 transition-colors">
|
||||
{modal.cancelText || 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className={`
|
||||
px-4 py-2 text-sm font-medium rounded-lg transition-colors
|
||||
${
|
||||
modal.destructive
|
||||
? 'bg-coral-500 hover:bg-coral-600 text-white'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
}
|
||||
`}>
|
||||
{modal.confirmText || 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
|
||||
interface ToastProps {
|
||||
notification: ToastNotification;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const TOAST_ICONS = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" 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>
|
||||
),
|
||||
};
|
||||
|
||||
const TOAST_STYLES = {
|
||||
success: 'bg-sage-500 text-white',
|
||||
error: 'bg-coral-500 text-white',
|
||||
warning: 'bg-amber-500 text-white',
|
||||
info: 'bg-primary-500 text-white',
|
||||
};
|
||||
|
||||
export function Toast({ notification, onRemove }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
onRemove(notification.id);
|
||||
}, 200);
|
||||
}, [onRemove, notification.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Animate in
|
||||
const showTimer = setTimeout(() => setIsVisible(true), 50);
|
||||
|
||||
// Auto remove after duration
|
||||
const duration = notification.duration || 4000;
|
||||
const removeTimer = setTimeout(() => {
|
||||
handleRemove();
|
||||
}, duration);
|
||||
|
||||
return () => {
|
||||
clearTimeout(showTimer);
|
||||
clearTimeout(removeTimer);
|
||||
};
|
||||
}, [notification, handleRemove]);
|
||||
|
||||
const icon = TOAST_ICONS[notification.type];
|
||||
const styles = TOAST_STYLES[notification.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transform transition-all duration-200 ease-in-out
|
||||
${isVisible && !isExiting ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||
${isExiting ? 'scale-95' : ''}
|
||||
`}>
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 p-4 rounded-lg shadow-large border backdrop-blur-sm
|
||||
max-w-sm w-full
|
||||
${styles}
|
||||
`}>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">{icon}</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium">{notification.title}</h4>
|
||||
{notification.message && (
|
||||
<p className="text-xs opacity-90 mt-1">{notification.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{notification.action && (
|
||||
<button
|
||||
onClick={notification.action.handler}
|
||||
className="text-xs font-medium underline hover:no-underline flex-shrink-0">
|
||||
{notification.action.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="flex-shrink-0 text-white/70 hover:text-white transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
notifications: ToastNotification[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ToastContainer({ notifications, onRemove }: ToastContainerProps) {
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
{notifications.map(notification => (
|
||||
<Toast key={notification.id} notification={notification} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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';
|
||||
@@ -0,0 +1,311 @@
|
||||
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.alphahuman.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',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ActionableItem, TimeGroup } from '../../types/intelligence';
|
||||
|
||||
/**
|
||||
* Groups actionable items by time periods (Today, Yesterday, This Week, Older)
|
||||
*/
|
||||
export function groupItemsByTime(items: ActionableItem[]): TimeGroup[] {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
const oneWeekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const groups: Record<string, ActionableItem[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
older: [],
|
||||
};
|
||||
|
||||
items.forEach(item => {
|
||||
const itemDate = new Date(item.createdAt);
|
||||
const itemDateOnly = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate());
|
||||
|
||||
if (itemDateOnly >= today) {
|
||||
groups.today.push(item);
|
||||
} else if (itemDateOnly >= yesterday) {
|
||||
groups.yesterday.push(item);
|
||||
} else if (itemDateOnly >= oneWeekAgo) {
|
||||
groups.thisWeek.push(item);
|
||||
} else {
|
||||
groups.older.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort items within each group by priority and then by date (newest first)
|
||||
const sortItems = (items: ActionableItem[]) => {
|
||||
const priorityOrder = { critical: 0, important: 1, normal: 2 };
|
||||
return items.sort((a, b) => {
|
||||
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
const timeGroups: TimeGroup[] = [];
|
||||
|
||||
if (groups.today.length > 0) {
|
||||
timeGroups.push({ label: 'Today', items: sortItems(groups.today), count: groups.today.length });
|
||||
}
|
||||
|
||||
if (groups.yesterday.length > 0) {
|
||||
timeGroups.push({
|
||||
label: 'Yesterday',
|
||||
items: sortItems(groups.yesterday),
|
||||
count: groups.yesterday.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.thisWeek.length > 0) {
|
||||
timeGroups.push({
|
||||
label: 'This Week',
|
||||
items: sortItems(groups.thisWeek),
|
||||
count: groups.thisWeek.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.older.length > 0) {
|
||||
timeGroups.push({ label: 'Older', items: sortItems(groups.older), count: groups.older.length });
|
||||
}
|
||||
|
||||
return timeGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters items based on various criteria
|
||||
*/
|
||||
export function filterItems(
|
||||
items: ActionableItem[],
|
||||
options: { source?: string; priority?: string; status?: string; searchTerm?: string }
|
||||
): ActionableItem[] {
|
||||
let filtered = [...items];
|
||||
|
||||
if (options.source && options.source !== 'all') {
|
||||
filtered = filtered.filter(item => item.source === options.source);
|
||||
}
|
||||
|
||||
if (options.priority && options.priority !== 'all') {
|
||||
filtered = filtered.filter(item => item.priority === options.priority);
|
||||
}
|
||||
|
||||
if (options.status && options.status !== 'all') {
|
||||
filtered = filtered.filter(item => item.status === options.status);
|
||||
}
|
||||
|
||||
if (options.searchTerm) {
|
||||
const term = options.searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
item =>
|
||||
item.title.toLowerCase().includes(term) ||
|
||||
item.description?.toLowerCase().includes(term) ||
|
||||
item.sourceLabel?.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets summary statistics for actionable items
|
||||
*/
|
||||
export function getItemStats(items: ActionableItem[]) {
|
||||
const total = items.length;
|
||||
const byPriority = items.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.priority]++;
|
||||
return acc;
|
||||
},
|
||||
{ critical: 0, important: 0, normal: 0 }
|
||||
);
|
||||
|
||||
const bySource = items.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.source] = (acc[item.source] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const newItems = items.filter(item => {
|
||||
const diff = Date.now() - item.createdAt.getTime();
|
||||
return diff < 5 * 60 * 1000; // Less than 5 minutes
|
||||
}).length;
|
||||
|
||||
const expiringSoon = items.filter(item => {
|
||||
if (!item.expiresAt) return false;
|
||||
const diff = item.expiresAt.getTime() - Date.now();
|
||||
return diff < 24 * 60 * 60 * 1000 && diff > 0; // Expires within 24 hours
|
||||
}).length;
|
||||
|
||||
return { total, byPriority, bySource, newItems, expiringSoon };
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { skillManager } from '../../lib/skills/manager';
|
||||
import { persistor } from '../../store';
|
||||
import { clearToken } from '../../store/authSlice';
|
||||
import { useAppDispatch } from '../../store/hooks';
|
||||
import { IS_DEV } from '../../utils/config';
|
||||
import { logout as tauriLogout } from '../../utils/tauriCommands';
|
||||
import SettingsHeader from './components/SettingsHeader';
|
||||
import SettingsMenuItem from './components/SettingsMenuItem';
|
||||
@@ -96,47 +95,10 @@ const SettingsHome = () => {
|
||||
// onClick: () => navigateToSettings("messaging"),
|
||||
// dangerous: false,
|
||||
// },
|
||||
{
|
||||
id: 'skills',
|
||||
title: 'Skills',
|
||||
description: 'Configure Slack, Discord, and other skills',
|
||||
devOnly: true,
|
||||
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.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('skills'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
title: 'AI Configuration',
|
||||
description: 'Configure SOUL persona and AI behavior',
|
||||
devOnly: true,
|
||||
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.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('ai'),
|
||||
dangerous: false,
|
||||
},
|
||||
// {
|
||||
// id: 'agent-chat',
|
||||
// title: 'Agent Chat',
|
||||
// description: 'Send messages directly to your agent',
|
||||
// devOnly: true,
|
||||
// icon: (
|
||||
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
// <path
|
||||
@@ -259,39 +221,20 @@ const SettingsHome = () => {
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'tauri-commands',
|
||||
title: 'Tauri Command Console',
|
||||
description: 'Run Alphahuman Tauri commands for quick testing',
|
||||
devOnly: true,
|
||||
id: 'developer-options',
|
||||
title: 'Developer Options',
|
||||
description: 'Skills, AI config, Tauri console, and memory debug tools',
|
||||
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 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('tauri-commands'),
|
||||
dangerous: false,
|
||||
},
|
||||
{
|
||||
id: 'memory-debug',
|
||||
title: 'Memory Debug',
|
||||
description: 'Inspect memory documents, namespaces, and test query/recall',
|
||||
devOnly: true,
|
||||
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 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
onClick: () => navigateToSettings('memory-debug'),
|
||||
onClick: () => navigateToSettings('developer-options'),
|
||||
dangerous: false,
|
||||
},
|
||||
];
|
||||
@@ -359,9 +302,7 @@ const SettingsHome = () => {
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Main Settings */}
|
||||
<div>
|
||||
{mainMenuItems
|
||||
.filter(item => !item.devOnly || IS_DEV)
|
||||
.map((item, index, filtered) => (
|
||||
{mainMenuItems.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
@@ -370,7 +311,7 @@ const SettingsHome = () => {
|
||||
onClick={item.onClick}
|
||||
dangerous={item.dangerous}
|
||||
isFirst={index === 0}
|
||||
isLast={index === filtered.length - 1}
|
||||
isLast={index === mainMenuItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ export type SettingsRoute =
|
||||
| 'team'
|
||||
| 'team-members'
|
||||
| 'team-invites'
|
||||
| 'developer-options'
|
||||
| 'skills'
|
||||
| 'ai'
|
||||
| 'tauri-commands'
|
||||
| 'memory-debug';
|
||||
@@ -45,6 +47,8 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
if (path.includes('/settings/profile')) return 'profile';
|
||||
if (path.includes('/settings/advanced')) return 'advanced';
|
||||
if (path.includes('/settings/billing')) return 'billing';
|
||||
if (path.includes('/settings/developer-options')) return 'developer-options';
|
||||
if (path.includes('/settings/skills')) return 'skills';
|
||||
if (path.includes('/settings/ai')) return 'ai';
|
||||
if (path.includes('/settings/tauri-commands')) return 'tauri-commands';
|
||||
if (path.includes('/settings/memory-debug')) return 'memory-debug';
|
||||
@@ -71,6 +75,8 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
[navigate]
|
||||
);
|
||||
|
||||
const developerSubRoutes: SettingsRoute[] = ['skills', 'ai', 'tauri-commands', 'memory-debug'];
|
||||
|
||||
const navigateBack = useCallback(() => {
|
||||
if (currentRoute === 'home') {
|
||||
navigate('/home');
|
||||
@@ -85,6 +91,8 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
|
||||
}
|
||||
} else if (location.pathname.includes('/team/manage/')) {
|
||||
navigate('/settings/team');
|
||||
} else if (developerSubRoutes.includes(currentRoute as SettingsRoute)) {
|
||||
navigate('/settings/developer-options');
|
||||
} else {
|
||||
navigate('/settings');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import SettingsMenuItem from '../components/SettingsMenuItem';
|
||||
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
|
||||
|
||||
const developerItems = [
|
||||
{
|
||||
id: 'skills',
|
||||
title: 'Skills',
|
||||
description: 'Configure Slack, Discord, and other skills',
|
||||
route: '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="M9.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ai',
|
||||
title: 'AI Configuration',
|
||||
description: 'Configure SOUL persona and AI behavior',
|
||||
route: 'ai',
|
||||
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.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'tauri-commands',
|
||||
title: 'Tauri Command Console',
|
||||
description: 'Run Alphahuman Tauri commands for quick testing',
|
||||
route: 'tauri-commands',
|
||||
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 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'memory-debug',
|
||||
title: 'Memory Debug',
|
||||
description: 'Inspect memory documents, namespaces, and test query/recall',
|
||||
route: 'memory-debug',
|
||||
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 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const DeveloperOptionsPanel = () => {
|
||||
const { navigateToSettings, navigateBack } = useSettingsNavigation();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden h-full flex flex-col z-10 relative">
|
||||
<SettingsHeader title="Developer Options" showBackButton={true} onBack={navigateBack} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto max-w-md mx-auto">
|
||||
<div className="p-4">
|
||||
<div>
|
||||
{developerItems.map((item, index) => (
|
||||
<SettingsMenuItem
|
||||
key={item.id}
|
||||
icon={item.icon}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
onClick={() => navigateToSettings(item.route)}
|
||||
isFirst={index === 0}
|
||||
isLast={index === developerItems.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeveloperOptionsPanel;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { IS_DEV } from '../../../utils/config';
|
||||
import {
|
||||
memoryDeleteDocument,
|
||||
memoryListDocuments,
|
||||
@@ -82,7 +81,6 @@ const MemoryDebugPanel = () => {
|
||||
}, [loadDocuments, loadNamespaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_DEV) return;
|
||||
void refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
@@ -134,17 +132,6 @@ const MemoryDebugPanel = () => {
|
||||
}
|
||||
}, [maxChunks, namespaceInput]);
|
||||
|
||||
if (!IS_DEV) {
|
||||
return (
|
||||
<div className="overflow-hidden h-full flex flex-col">
|
||||
<SettingsHeader title="Memory Debug" showBackButton={true} onBack={navigateBack} />
|
||||
<div className="flex-1 overflow-y-auto p-4 text-sm text-stone-400">
|
||||
Memory debug is available only in development mode.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden h-full flex flex-col">
|
||||
<SettingsHeader title="Memory Debug" showBackButton={true} onBack={navigateBack} />
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { MOCK_ACTIONABLE_ITEMS } from '../components/intelligence/mockData';
|
||||
import { intelligenceApi, type ConnectedTool } from '../services/intelligenceApi';
|
||||
import type { ActionableItem, ActionableItemStatus } from '../types/intelligence';
|
||||
import { transformBackendItemsToFrontend, transformBackendMessagesToFrontend } from '../utils/intelligenceTransforms';
|
||||
|
||||
/**
|
||||
* Fallback implementation of Intelligence API hooks without React Query
|
||||
* Used when React Query is not available
|
||||
*/
|
||||
|
||||
interface UseActionableItemsResult {
|
||||
data: ActionableItem[] | undefined;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching actionable items (fallback version)
|
||||
* TODO: Remove MOCK_ACTIONABLE_ITEMS once backend APIs are ready
|
||||
*/
|
||||
export const useActionableItems = (options?: {
|
||||
refetchInterval?: number;
|
||||
enabled?: boolean;
|
||||
}): UseActionableItemsResult => {
|
||||
const [data, setData] = useState<ActionableItem[] | undefined>(MOCK_ACTIONABLE_ITEMS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
if (options?.enabled === false) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const backendItems = await intelligenceApi.getActionableItems();
|
||||
const items = transformBackendItemsToFrontend(backendItems);
|
||||
setData(items.length > 0 ? items : MOCK_ACTIONABLE_ITEMS);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch items';
|
||||
setError(errorMessage);
|
||||
console.error('Failed to fetch actionable items:', err);
|
||||
// TODO: Replace with actual data
|
||||
setData(MOCK_ACTIONABLE_ITEMS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [options?.enabled]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
// Set up refetch interval
|
||||
useEffect(() => {
|
||||
if (options?.refetchInterval) {
|
||||
const interval = setInterval(fetchItems, options.refetchInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [options?.refetchInterval, fetchItems]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchItems,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseUpdateActionableItemResult {
|
||||
mutateAsync: (variables: {
|
||||
itemId: string;
|
||||
status: ActionableItemStatus;
|
||||
}) => Promise<{ itemId: string; status: ActionableItemStatus; updatedAt: Date }>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for updating actionable item status (fallback version)
|
||||
*/
|
||||
export const useUpdateActionableItem = (): UseUpdateActionableItemResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutateAsync = useCallback(async (variables: {
|
||||
itemId: string;
|
||||
status: ActionableItemStatus;
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await intelligenceApi.updateItemStatus(variables.itemId, variables.status);
|
||||
return { ...variables, updatedAt: new Date() };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update item';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mutateAsync,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseSnoozeActionableItemResult {
|
||||
mutateAsync: (variables: {
|
||||
itemId: string;
|
||||
snoozeUntil: Date;
|
||||
}) => Promise<{ itemId: string; snoozeUntil: Date; updatedAt: Date }>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for snoozing actionable item (fallback version)
|
||||
*/
|
||||
export const useSnoozeActionableItem = (): UseSnoozeActionableItemResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutateAsync = useCallback(async (variables: {
|
||||
itemId: string;
|
||||
snoozeUntil: Date;
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await intelligenceApi.snoozeItem(variables.itemId, variables.snoozeUntil);
|
||||
return { ...variables, updatedAt: new Date() };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to snooze item';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mutateAsync,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseChatSessionResult {
|
||||
data: {
|
||||
threadId: string;
|
||||
messages: any[];
|
||||
} | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for creating or getting chat session (fallback version)
|
||||
*/
|
||||
export const useChatSession = (itemId: string | null): UseChatSessionResult => {
|
||||
const [data, setData] = useState<{ threadId: string; messages: any[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemId) return;
|
||||
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await intelligenceApi.getOrCreateThread(itemId);
|
||||
setData({
|
||||
threadId: response.threadId,
|
||||
messages: transformBackendMessagesToFrontend(response.messages),
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create session';
|
||||
setError(errorMessage);
|
||||
console.error('Failed to create chat session:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSession();
|
||||
}, [itemId]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseExecuteTaskResult {
|
||||
mutateAsync: (variables: {
|
||||
itemId: string;
|
||||
connectedTools: ConnectedTool[];
|
||||
}) => Promise<{
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
status: string;
|
||||
}>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for executing tasks (fallback version)
|
||||
*/
|
||||
export const useExecuteTask = (): UseExecuteTaskResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutateAsync = useCallback(async (variables: {
|
||||
itemId: string;
|
||||
connectedTools: ConnectedTool[];
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await intelligenceApi.executeTask(variables.itemId, variables.connectedTools);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to execute task';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mutateAsync,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
// Export query key utilities for consistency
|
||||
export const intelligenceKeys = {
|
||||
all: ['intelligence'] as const,
|
||||
items: () => [...intelligenceKeys.all, 'items'] as const,
|
||||
item: (id: string) => [...intelligenceKeys.all, 'item', id] as const,
|
||||
thread: (itemId: string) => [...intelligenceKeys.all, 'thread', itemId] as const,
|
||||
messages: (threadId: string) => [...intelligenceKeys.all, 'messages', threadId] as const,
|
||||
execution: (executionId: string) => [...intelligenceKeys.all, 'execution', executionId] as const,
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { socketService } from '../services/socketService';
|
||||
import type { RootState } from '../store';
|
||||
import { addMessage, setExecutionResult, setTyping, updateExecutionProgress } from '../store/intelligenceSlice';
|
||||
import { createChatMessage } from '../utils/intelligenceTransforms';
|
||||
import { emitViaRustSocket } from '../utils/tauriSocket';
|
||||
|
||||
/**
|
||||
* WebSocket event payloads for Intelligence system
|
||||
*/
|
||||
interface ProcessMessagePayload {
|
||||
message: string;
|
||||
threadId: string;
|
||||
sessionId?: string;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface AgentResponsePayload {
|
||||
message: string;
|
||||
threadId: string;
|
||||
sessionId?: string;
|
||||
shouldExecute?: boolean;
|
||||
executionPlan?: any;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ExecutionProgressPayload {
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
step: {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
timestamp: string;
|
||||
};
|
||||
progress: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
timestamp?: Date;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ExecutionCompletePayload {
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
status: 'completed' | 'failed';
|
||||
result?: any;
|
||||
error?: string;
|
||||
artifacts?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ChatInitPayload {
|
||||
tools: any[];
|
||||
threadId: string;
|
||||
sessionId?: string;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in Tauri environment for socket routing
|
||||
*/
|
||||
function isTauri(): boolean {
|
||||
try {
|
||||
return (window as any)?.__TAURI__ !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for Intelligence WebSocket integration
|
||||
* Handles real-time communication for chat and task execution
|
||||
*/
|
||||
export const useIntelligenceSocket = () => {
|
||||
const dispatch = useDispatch();
|
||||
const socket = socketService.getSocket();
|
||||
const eventHandlersRegistered = useRef(false);
|
||||
|
||||
// Get current socket connection status
|
||||
const isSocketConnected = useSelector((state: RootState) => {
|
||||
const userId = state.user.user?._id || '__pending__';
|
||||
return state.socket.byUser[userId]?.status === 'connected';
|
||||
});
|
||||
|
||||
/**
|
||||
* Send message to AI agent via WebSocket
|
||||
*/
|
||||
const sendMessage = useCallback(async (payload: ProcessMessagePayload) => {
|
||||
if (isTauri()) {
|
||||
// Use Rust socket for Tauri environment
|
||||
emitViaRustSocket('processMessageForUser', payload);
|
||||
} else if (socket?.connected) {
|
||||
socket.emit('processMessageForUser', payload);
|
||||
} else {
|
||||
console.warn('Cannot send message - socket not connected');
|
||||
throw new Error('Socket not connected');
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Initialize chat session with tools
|
||||
*/
|
||||
const sendChatInit = useCallback(async (payload: ChatInitPayload) => {
|
||||
if (isTauri()) {
|
||||
emitViaRustSocket('chat:init', payload);
|
||||
} else if (socket?.connected) {
|
||||
socket.emit('chat:init', payload);
|
||||
} else {
|
||||
console.warn('Cannot initialize chat - socket not connected');
|
||||
throw new Error('Socket not connected');
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Send typing indicator
|
||||
*/
|
||||
const sendTyping = useCallback((threadId: string, isTyping: boolean) => {
|
||||
const payload = { threadId, isTyping };
|
||||
|
||||
if (isTauri()) {
|
||||
emitViaRustSocket('chat:typing', payload);
|
||||
} else if (socket?.connected) {
|
||||
socket.emit('chat:typing', payload);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Register WebSocket event handlers
|
||||
*/
|
||||
const registerEventHandlers = useCallback(() => {
|
||||
if (!socket || eventHandlersRegistered.current) return;
|
||||
|
||||
// Agent response handler
|
||||
const handleAgentResponse = (data: AgentResponsePayload) => {
|
||||
console.log('Intelligence: Received agent response', {
|
||||
threadId: data.threadId,
|
||||
hasMessage: !!data.message,
|
||||
shouldExecute: data.shouldExecute,
|
||||
});
|
||||
|
||||
if (data.message && data.threadId) {
|
||||
const aiMessage = createChatMessage(data.message, 'ai');
|
||||
dispatch(addMessage({
|
||||
threadId: data.threadId,
|
||||
message: aiMessage,
|
||||
}));
|
||||
}
|
||||
|
||||
// Stop typing indicator
|
||||
dispatch(setTyping({
|
||||
threadId: data.threadId,
|
||||
isTyping: false,
|
||||
}));
|
||||
|
||||
// Handle execution trigger
|
||||
if (data.shouldExecute && data.executionPlan) {
|
||||
console.log('Intelligence: Execution requested', {
|
||||
threadId: data.threadId,
|
||||
plan: data.executionPlan,
|
||||
});
|
||||
// Execution will be handled by the component
|
||||
}
|
||||
};
|
||||
|
||||
// Execution progress handler
|
||||
const handleExecutionProgress = (data: ExecutionProgressPayload) => {
|
||||
console.log('Intelligence: Execution progress', {
|
||||
executionId: data.executionId,
|
||||
step: data.step?.label,
|
||||
status: data.step?.status,
|
||||
});
|
||||
|
||||
if (data.progress) {
|
||||
dispatch(updateExecutionProgress({
|
||||
executionId: data.executionId,
|
||||
progress: data.progress,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Execution complete handler
|
||||
const handleExecutionComplete = (data: ExecutionCompletePayload) => {
|
||||
console.log('Intelligence: Execution complete', {
|
||||
executionId: data.executionId,
|
||||
status: data.status,
|
||||
hasResult: !!data.result,
|
||||
hasArtifacts: !!data.artifacts?.length,
|
||||
});
|
||||
|
||||
dispatch(setExecutionResult({
|
||||
executionId: data.executionId,
|
||||
result: data.result,
|
||||
status: data.status,
|
||||
error: data.error,
|
||||
}));
|
||||
|
||||
// Send completion message if we have artifacts
|
||||
if (data.artifacts?.length && data.sessionId) {
|
||||
// This would need the threadId - we'd need to track execution to thread mapping
|
||||
// For now, we'll let the component handle the completion message
|
||||
console.log('Intelligence: Task completed with artifacts', {
|
||||
artifactCount: data.artifacts.length,
|
||||
sessionId: data.sessionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Typing indicator handler
|
||||
const handleTyping = (data: { threadId: string; isTyping: boolean }) => {
|
||||
dispatch(setTyping({
|
||||
threadId: data.threadId,
|
||||
isTyping: data.isTyping,
|
||||
}));
|
||||
};
|
||||
|
||||
// Register all handlers
|
||||
socket.on('agentResponse', handleAgentResponse);
|
||||
socket.on('execution:step_progress', handleExecutionProgress);
|
||||
socket.on('execution:complete', handleExecutionComplete);
|
||||
socket.on('chat:typing', handleTyping);
|
||||
|
||||
eventHandlersRegistered.current = true;
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
socket.off('agentResponse', handleAgentResponse);
|
||||
socket.off('execution:step_progress', handleExecutionProgress);
|
||||
socket.off('execution:complete', handleExecutionComplete);
|
||||
socket.off('chat:typing', handleTyping);
|
||||
eventHandlersRegistered.current = false;
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
/**
|
||||
* Register event handlers when socket is available
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (socket && isSocketConnected) {
|
||||
const cleanup = registerEventHandlers();
|
||||
return cleanup;
|
||||
}
|
||||
}, [socket, isSocketConnected, registerEventHandlers]);
|
||||
|
||||
/**
|
||||
* Cleanup on unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
eventHandlersRegistered.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Connection status
|
||||
isConnected: isSocketConnected,
|
||||
|
||||
// Message sending
|
||||
sendMessage,
|
||||
sendChatInit,
|
||||
sendTyping,
|
||||
|
||||
// Utility functions
|
||||
isReady: isSocketConnected && !!socket,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing Intelligence WebSocket connection lifecycle
|
||||
*/
|
||||
export const useIntelligenceSocketManager = () => {
|
||||
const token = useSelector((state: RootState) => state.auth.token);
|
||||
const isConnected = useSelector((state: RootState) => {
|
||||
const userId = state.user.user?._id || '__pending__';
|
||||
return state.socket.byUser[userId]?.status === 'connected';
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize Intelligence socket connection
|
||||
*/
|
||||
const connect = useCallback(() => {
|
||||
if (token && !isConnected) {
|
||||
console.log('Intelligence: Initializing socket connection');
|
||||
socketService.connect(token);
|
||||
}
|
||||
}, [token, isConnected]);
|
||||
|
||||
/**
|
||||
* Disconnect Intelligence socket
|
||||
*/
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('Intelligence: Disconnecting socket');
|
||||
socketService.disconnect();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Auto-connect when token is available
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (token && !isConnected) {
|
||||
connect();
|
||||
}
|
||||
}, [token, isConnected, connect]);
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
isReady: isConnected && !!token,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for Intelligence-specific event subscriptions
|
||||
*/
|
||||
export const useIntelligenceEvents = () => {
|
||||
const socket = socketService.getSocket();
|
||||
|
||||
/**
|
||||
* Subscribe to agent responses for a specific thread
|
||||
*/
|
||||
const onAgentResponse = useCallback((
|
||||
threadId: string,
|
||||
callback: (data: AgentResponsePayload) => void
|
||||
) => {
|
||||
if (!socket) return () => {};
|
||||
|
||||
const handler = (data: AgentResponsePayload) => {
|
||||
if (data.threadId === threadId) {
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('agentResponse', handler);
|
||||
return () => socket.off('agentResponse', handler);
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Subscribe to execution progress for a specific execution
|
||||
*/
|
||||
const onExecutionProgress = useCallback((
|
||||
executionId: string,
|
||||
callback: (data: ExecutionProgressPayload) => void
|
||||
) => {
|
||||
if (!socket) return () => {};
|
||||
|
||||
const handler = (data: ExecutionProgressPayload) => {
|
||||
if (data.executionId === executionId) {
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('execution:step_progress', handler);
|
||||
return () => socket.off('execution:step_progress', handler);
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Subscribe to execution completion for a specific execution
|
||||
*/
|
||||
const onExecutionComplete = useCallback((
|
||||
executionId: string,
|
||||
callback: (data: ExecutionCompletePayload) => void
|
||||
) => {
|
||||
if (!socket) return () => {};
|
||||
|
||||
const handler = (data: ExecutionCompletePayload) => {
|
||||
if (data.executionId === executionId) {
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('execution:complete', handler);
|
||||
return () => socket.off('execution:complete', handler);
|
||||
}, [socket]);
|
||||
|
||||
return {
|
||||
onAgentResponse,
|
||||
onExecutionProgress,
|
||||
onExecutionComplete,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Chat tools initialization for Intelligence system.
|
||||
*
|
||||
* Provides functionality to gather all active skills' tools and send them
|
||||
* to the backend when a chat session is initialized, enabling AI to understand
|
||||
* available capabilities for task execution.
|
||||
*/
|
||||
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
|
||||
import debug from 'debug';
|
||||
|
||||
import type { ConnectedTool } from '../../services/intelligenceApi';
|
||||
import { socketService } from '../../services/socketService';
|
||||
import { store } from '../../store';
|
||||
import { emitViaRustSocket } from '../../utils/tauriSocket';
|
||||
import { transformMCPToConnectedTools } from '../../utils/intelligenceTransforms';
|
||||
import type { MCPTool } from '../mcp';
|
||||
import { deriveConnectionStatus } from '../skills/hooks';
|
||||
|
||||
// Chat tools logger using debug package
|
||||
const chatToolsLog = debug('chat:tools');
|
||||
const chatToolsWarn = debug('chat:tools:warn');
|
||||
|
||||
// Enable logging in development
|
||||
if (import.meta.env.DEV || import.meta.env.MODE === 'development') {
|
||||
debug.enable('chat:tools*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in Tauri environment
|
||||
*/
|
||||
function isTauri(): boolean {
|
||||
try {
|
||||
return coreIsTauri();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for chat initialization payload
|
||||
*/
|
||||
export interface ChatInitPayload {
|
||||
tools: MCPTool[];
|
||||
connectedTools: ConnectedTool[];
|
||||
sessionId?: string;
|
||||
threadId?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather all tools from active skills and send them to the backend
|
||||
* when initializing a chat session.
|
||||
*
|
||||
* This function:
|
||||
* 1. Retrieves all skills from Redux store
|
||||
* 2. Filters for active/ready skills with completed setup
|
||||
* 3. Extracts and formats tool definitions in both MCP and Connected formats
|
||||
* 4. Emits 'chat:init' event via appropriate socket method
|
||||
*
|
||||
* @param sessionId - Optional chat session identifier
|
||||
* @param threadId - Optional thread identifier for chat session
|
||||
* @param useConnectedFormat - Whether to send tools in Connected format (default: true)
|
||||
*/
|
||||
export function initializeChatWithTools(
|
||||
sessionId?: string,
|
||||
threadId?: string,
|
||||
useConnectedFormat: boolean = true
|
||||
): void {
|
||||
try {
|
||||
const state = store.getState();
|
||||
const skills = state.skills.skills;
|
||||
const skillStates = state.skills.skillStates;
|
||||
|
||||
chatToolsLog('Initializing chat with tools', {
|
||||
sessionId,
|
||||
threadId,
|
||||
useConnectedFormat,
|
||||
totalSkills: Object.keys(skills).length,
|
||||
});
|
||||
|
||||
const activeTools: MCPTool[] = [];
|
||||
|
||||
// Process each skill to extract active tools
|
||||
for (const [skillId, skill] of Object.entries(skills)) {
|
||||
// Derive connection status using existing logic
|
||||
const connectionStatus = deriveConnectionStatus(
|
||||
skill.status,
|
||||
skill.setupComplete,
|
||||
skillStates[skillId]
|
||||
);
|
||||
|
||||
// Only include tools from skills that are ready and properly connected
|
||||
const isSkillActive =
|
||||
(skill.status === 'ready' || skill.status === 'running') &&
|
||||
skill.setupComplete &&
|
||||
(connectionStatus === 'connected' || connectionStatus === 'connecting'); // Include connecting state for gradual initialization
|
||||
|
||||
if (isSkillActive && skill.tools?.length) {
|
||||
chatToolsLog('Processing tools for active skill', {
|
||||
skillId,
|
||||
skillName: skill.manifest.name,
|
||||
toolCount: skill.tools.length,
|
||||
status: skill.status,
|
||||
connectionStatus,
|
||||
});
|
||||
|
||||
// Transform skill tools to MCP format with skill prefix
|
||||
for (const tool of skill.tools) {
|
||||
activeTools.push({
|
||||
name: `${skillId}__${tool.name}`,
|
||||
description: `${skill.manifest.name}: ${tool.description}`,
|
||||
inputSchema: tool.inputSchema,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
chatToolsLog('Skipping inactive skill', {
|
||||
skillId,
|
||||
skillName: skill.manifest.name,
|
||||
status: skill.status,
|
||||
setupComplete: skill.setupComplete,
|
||||
connectionStatus,
|
||||
toolCount: skill.tools?.length || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create tools breakdown by skill for detailed logging
|
||||
const toolsBySkill: Record<string, { skillName: string; tools: string[] }> = {};
|
||||
activeTools.forEach(tool => {
|
||||
const [skillId] = tool.name.split('__');
|
||||
const skill = skills[skillId];
|
||||
if (skill) {
|
||||
if (!toolsBySkill[skillId]) {
|
||||
toolsBySkill[skillId] = { skillName: skill.manifest.name, tools: [] };
|
||||
}
|
||||
toolsBySkill[skillId].tools.push(tool.name.split('__')[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform to connected tools format if requested
|
||||
const connectedTools = useConnectedFormat ? transformMCPToConnectedTools(activeTools) : [];
|
||||
|
||||
// Prepare chat initialization payload
|
||||
const payload: ChatInitPayload = {
|
||||
tools: activeTools,
|
||||
connectedTools,
|
||||
sessionId,
|
||||
threadId,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Detailed logging of available tools
|
||||
chatToolsLog('🛠️ AVAILABLE TOOLS FOR CHAT INITIALIZATION', {
|
||||
sessionId,
|
||||
threadId,
|
||||
totalTools: activeTools.length,
|
||||
totalConnectedTools: connectedTools.length,
|
||||
totalSkills: Object.keys(toolsBySkill).length,
|
||||
useConnectedFormat,
|
||||
});
|
||||
|
||||
// Log tools breakdown by skill
|
||||
Object.entries(toolsBySkill).forEach(([skillId, { skillName, tools }]) => {
|
||||
chatToolsLog(`📦 ${skillName} (${skillId}):`, { toolCount: tools.length, tools });
|
||||
});
|
||||
|
||||
// Log complete tools list with descriptions
|
||||
if (activeTools.length > 0) {
|
||||
chatToolsLog(
|
||||
'📋 Complete Tools List:',
|
||||
activeTools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description.slice(0, 80) + (tool.description.length > 80 ? '...' : ''),
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
chatToolsLog('⚠️ No active tools available - chat will have no tool capabilities');
|
||||
}
|
||||
|
||||
// Emit via appropriate socket method based on environment
|
||||
if (isTauri()) {
|
||||
chatToolsLog('Emitting chat:init via Rust socket');
|
||||
emitViaRustSocket('chat:init', payload);
|
||||
} else {
|
||||
chatToolsLog('Emitting chat:init via web socket');
|
||||
if (socketService.isConnected()) {
|
||||
socketService.emit('chat:init', payload);
|
||||
} else {
|
||||
chatToolsWarn('Socket not connected - chat initialization may be delayed');
|
||||
// Could potentially queue the initialization for when socket connects
|
||||
}
|
||||
}
|
||||
|
||||
chatToolsLog('Chat tools initialization completed', {
|
||||
sessionId,
|
||||
threadId,
|
||||
toolCount: activeTools.length,
|
||||
connectedToolCount: connectedTools.length,
|
||||
environment: isTauri() ? 'tauri' : 'web',
|
||||
});
|
||||
} catch (error) {
|
||||
chatToolsWarn('Failed to initialize chat with tools', {
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Don't throw - allow chat to continue without tools if needed
|
||||
// The backend should handle empty tools gracefully
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Intelligence chat session with both MCP and Connected tools formats
|
||||
* @param sessionId - Chat session identifier
|
||||
* @param threadId - Thread identifier for chat session
|
||||
* @param connectedTools - Pre-transformed connected tools (optional)
|
||||
*/
|
||||
export function initializeIntelligenceChatSession(
|
||||
sessionId: string,
|
||||
threadId: string,
|
||||
connectedTools?: ConnectedTool[]
|
||||
): void {
|
||||
try {
|
||||
const mcpTools = getCurrentActiveTools();
|
||||
const finalConnectedTools = connectedTools || transformMCPToConnectedTools(mcpTools);
|
||||
|
||||
const payload = {
|
||||
tools: finalConnectedTools, // Intelligence system expects Connected format
|
||||
sessionId,
|
||||
threadId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
chatToolsLog('Intelligence: Initializing chat session', {
|
||||
sessionId,
|
||||
threadId,
|
||||
toolCount: finalConnectedTools.length,
|
||||
});
|
||||
|
||||
// Emit via appropriate socket method based on environment
|
||||
if (isTauri()) {
|
||||
chatToolsLog('Intelligence: Emitting chat:init via Rust socket');
|
||||
emitViaRustSocket('chat:init', payload);
|
||||
} else {
|
||||
chatToolsLog('Intelligence: Emitting chat:init via web socket');
|
||||
if (socketService.isConnected()) {
|
||||
socketService.emit('chat:init', payload);
|
||||
} else {
|
||||
chatToolsWarn('Intelligence: Socket not connected - chat initialization may be delayed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
chatToolsWarn('Intelligence: Failed to initialize chat session', {
|
||||
sessionId,
|
||||
threadId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active tools without emitting (for debugging/display purposes)
|
||||
*/
|
||||
export function getCurrentActiveTools(): MCPTool[] {
|
||||
try {
|
||||
const state = store.getState();
|
||||
const skills = state.skills.skills;
|
||||
const skillStates = state.skills.skillStates;
|
||||
const activeTools: MCPTool[] = [];
|
||||
|
||||
for (const [skillId, skill] of Object.entries(skills)) {
|
||||
const connectionStatus = deriveConnectionStatus(
|
||||
skill.status,
|
||||
skill.setupComplete,
|
||||
skillStates[skillId]
|
||||
);
|
||||
|
||||
const isSkillActive =
|
||||
(skill.status === 'ready' || skill.status === 'running') &&
|
||||
skill.setupComplete &&
|
||||
(connectionStatus === 'connected' || connectionStatus === 'connecting');
|
||||
|
||||
if (isSkillActive && skill.tools?.length) {
|
||||
for (const tool of skill.tools) {
|
||||
activeTools.push({
|
||||
name: `${skillId}__${tool.name}`,
|
||||
description: `${skill.manifest.name}: ${tool.description}`,
|
||||
inputSchema: tool.inputSchema,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return activeTools;
|
||||
} catch (error) {
|
||||
chatToolsWarn('Failed to get current active tools', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active tools in Connected format for Intelligence system
|
||||
*/
|
||||
export function getCurrentConnectedTools(): ConnectedTool[] {
|
||||
try {
|
||||
const mcpTools = getCurrentActiveTools();
|
||||
return transformMCPToConnectedTools(mcpTools);
|
||||
} catch (error) {
|
||||
chatToolsWarn('Failed to get current connected tools', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+17
-49
@@ -44,7 +44,6 @@ import {
|
||||
setLastViewed,
|
||||
setPanelWidth,
|
||||
setSelectedThread,
|
||||
updateMessagesForThread,
|
||||
} from '../store/threadSlice';
|
||||
import type { ThreadMessage } from '../types/thread';
|
||||
|
||||
@@ -344,31 +343,19 @@ const Conversations = () => {
|
||||
},
|
||||
onError: event => {
|
||||
if (event.thread_id !== selectedThreadIdRef.current) return;
|
||||
if (event.error_type !== 'cancelled') {
|
||||
setSendError(event.message);
|
||||
}
|
||||
setIsSending(false);
|
||||
setActiveToolCall(null);
|
||||
dispatch(setActiveThread(null));
|
||||
|
||||
// Remove the optimistic user message on error
|
||||
dispatch((innerDispatch, getState) => {
|
||||
const state = getState() as {
|
||||
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
|
||||
};
|
||||
const persistedMessages = state.thread.messagesByThreadId[event.thread_id] || [];
|
||||
const lastUserIdx = [...persistedMessages]
|
||||
.reverse()
|
||||
.findIndex(m => m.sender === 'user');
|
||||
if (lastUserIdx !== -1) {
|
||||
const actualIdx = persistedMessages.length - 1 - lastUserIdx;
|
||||
const updated = persistedMessages.filter((_, i) => i !== actualIdx);
|
||||
innerDispatch(updateMessagesForThread({ threadId: event.thread_id, messages: updated }));
|
||||
if (event.thread_id === selectedThreadIdRef.current) {
|
||||
innerDispatch(setSelectedThread(event.thread_id));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (event.error_type !== 'cancelled') {
|
||||
dispatch(
|
||||
addInferenceResponse({
|
||||
content: 'Something went wrong — please try again.',
|
||||
threadId: event.thread_id,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
dispatch(setActiveThread(null));
|
||||
}
|
||||
},
|
||||
}).then(fn => {
|
||||
if (mounted) cleanup = fn;
|
||||
@@ -633,32 +620,13 @@ const Conversations = () => {
|
||||
|
||||
// Pass the original sending thread ID to ensure response goes to correct thread
|
||||
dispatch(addInferenceResponse({ content: finalContent, threadId: sendingThreadId }));
|
||||
} catch (err) {
|
||||
// Remove the user message from persistent storage on error
|
||||
// We'll use a thunk-like approach to access current state
|
||||
dispatch((innerDispatch, getState) => {
|
||||
const state = getState() as {
|
||||
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
|
||||
};
|
||||
const persistedMessages = state.thread.messagesByThreadId[sendingThreadId] || [];
|
||||
const currentMessages = persistedMessages.filter(m => m.id !== userMessage.id);
|
||||
innerDispatch(
|
||||
updateMessagesForThread({ threadId: sendingThreadId, messages: currentMessages })
|
||||
);
|
||||
|
||||
// Also remove from current view if this is the selected thread
|
||||
if (sendingThreadId === selectedThreadId) {
|
||||
innerDispatch(setSelectedThread(sendingThreadId));
|
||||
}
|
||||
});
|
||||
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'error' in err
|
||||
? String((err as { error: unknown }).error)
|
||||
: 'Failed to get response';
|
||||
setSendError(msg);
|
||||
// Clear active thread on error
|
||||
dispatch(setActiveThread(null));
|
||||
} catch {
|
||||
dispatch(
|
||||
addInferenceResponse({
|
||||
content: 'Something went wrong — please try again.',
|
||||
threadId: sendingThreadId,
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(safetyTimeout);
|
||||
setIsSending(false);
|
||||
|
||||
+375
-20
@@ -1,31 +1,386 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { ActionableCard } from '../components/intelligence/ActionableCard';
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import { filterItems, getItemStats, groupItemsByTime } from '../components/intelligence/utils';
|
||||
import {
|
||||
useActionableItems,
|
||||
useSnoozeActionableItem,
|
||||
useUpdateActionableItem,
|
||||
} from '../hooks/useIntelligenceApiFallback';
|
||||
import { useIntelligenceSocket, useIntelligenceSocketManager } from '../hooks/useIntelligenceSocket';
|
||||
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
|
||||
import type { RootState } from '../store';
|
||||
import {
|
||||
setSourceFilter,
|
||||
setSearchFilter,
|
||||
} from '../store/intelligenceSlice';
|
||||
import type {
|
||||
ActionableItem,
|
||||
ActionableItemStatus,
|
||||
ConfirmationModal as ConfirmationModalType,
|
||||
ToastNotification,
|
||||
} from '../types/intelligence';
|
||||
|
||||
export default function Intelligence() {
|
||||
const dispatch = useDispatch();
|
||||
const { aiStatus } = useIntelligenceStats();
|
||||
|
||||
// Redux state
|
||||
const intelligenceState = useSelector((state: RootState) => state.intelligence);
|
||||
const { filters } = intelligenceState;
|
||||
|
||||
// API hooks
|
||||
const {
|
||||
data: apiItems,
|
||||
loading: itemsLoading,
|
||||
error: itemsError,
|
||||
refetch: refetchItems,
|
||||
} = useActionableItems();
|
||||
|
||||
const { mutateAsync: updateItemStatus } = useUpdateActionableItem();
|
||||
const { mutateAsync: snoozeItem } = useSnoozeActionableItem();
|
||||
|
||||
// Socket integration
|
||||
const socketManager = useIntelligenceSocketManager();
|
||||
const { isConnected: socketConnected } = useIntelligenceSocket();
|
||||
|
||||
// Local state for UI
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
const [confirmationModal, setConfirmationModal] = useState<ConfirmationModalType>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
|
||||
// Use API data or fallback to empty array
|
||||
const items = apiItems || [];
|
||||
|
||||
// Initialize socket connection
|
||||
useEffect(() => {
|
||||
if (!socketConnected) {
|
||||
socketManager.connect();
|
||||
}
|
||||
}, [socketConnected, socketManager]);
|
||||
|
||||
// Handle API errors with toast notifications
|
||||
useEffect(() => {
|
||||
if (itemsError) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Failed to load items',
|
||||
message: typeof itemsError === 'string' ? itemsError : 'Unable to fetch actionable items',
|
||||
});
|
||||
}
|
||||
}, [itemsError]);
|
||||
|
||||
// Filter and group items
|
||||
const filteredItems = useMemo(() => {
|
||||
const activeItems = items.filter(item => item.status === 'active');
|
||||
return filterItems(activeItems, {
|
||||
source: filters.source,
|
||||
priority: filters.priority,
|
||||
searchTerm: filters.search,
|
||||
});
|
||||
}, [items, filters.source, filters.priority, filters.search]);
|
||||
|
||||
const timeGroups = useMemo(() => {
|
||||
return groupItemsByTime(filteredItems);
|
||||
}, [filteredItems]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return getItemStats(filteredItems);
|
||||
}, [filteredItems]);
|
||||
|
||||
// Toast utilities
|
||||
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
|
||||
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
// Item action handlers with real backend integration
|
||||
const handleUpdateItemStatus = useCallback(async (itemId: string, status: ActionableItemStatus) => {
|
||||
try {
|
||||
await updateItemStatus({ itemId, status });
|
||||
|
||||
// Success toast
|
||||
let message = '';
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
message = 'Task marked as completed';
|
||||
break;
|
||||
case 'dismissed':
|
||||
message = 'Task dismissed';
|
||||
break;
|
||||
case 'active':
|
||||
message = 'Task reactivated';
|
||||
break;
|
||||
default:
|
||||
message = 'Status updated';
|
||||
}
|
||||
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Status Updated',
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update item status:', error);
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Update Failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to update item status',
|
||||
});
|
||||
}
|
||||
}, [updateItemStatus]);
|
||||
|
||||
const handleComplete = useCallback(async (item: ActionableItem) => {
|
||||
await handleUpdateItemStatus(item.id, 'completed');
|
||||
}, [handleUpdateItemStatus]);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(item: ActionableItem) => {
|
||||
// Always show confirmation modal for ALL dismiss actions
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
title: 'Dismiss item?',
|
||||
message: `Are you sure you want to dismiss "${item.title}"?`,
|
||||
confirmText: 'Dismiss',
|
||||
cancelText: 'Cancel',
|
||||
destructive: item.priority === 'critical',
|
||||
showDontShowAgain: !item.requiresConfirmation,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await handleUpdateItemStatus(item.id, 'dismissed');
|
||||
|
||||
// Add undo action to toast
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Dismissed',
|
||||
message: item.title.length > 40 ? `${item.title.substring(0, 40)}...` : item.title,
|
||||
action: {
|
||||
label: 'Undo',
|
||||
handler: () => handleUpdateItemStatus(item.id, 'active'),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss item:', error);
|
||||
}
|
||||
},
|
||||
onCancel: () => {},
|
||||
});
|
||||
},
|
||||
[handleUpdateItemStatus, addToast]
|
||||
);
|
||||
|
||||
const handleSnooze = useCallback(
|
||||
async (item: ActionableItem, duration: number) => {
|
||||
try {
|
||||
const snoozeUntil = new Date(Date.now() + duration);
|
||||
await snoozeItem({ itemId: item.id, snoozeUntil });
|
||||
|
||||
const hours = Math.round(duration / (1000 * 60 * 60));
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Snoozed',
|
||||
message: `Reminded in ${hours === 1 ? '1 hour' : `${hours} hours`}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to snooze item:', error);
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Snooze Failed',
|
||||
message: 'Failed to snooze item. Please try again.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[snoozeItem, addToast]
|
||||
);
|
||||
|
||||
// Combined AI and socket status indicator
|
||||
const systemStatus = socketConnected && aiStatus === 'ready' ? 'ready' :
|
||||
itemsLoading ? 'loading' :
|
||||
!socketConnected ? 'disconnected' : aiStatus;
|
||||
|
||||
const systemStatusLabel =
|
||||
systemStatus === 'ready' ? 'System Ready' :
|
||||
systemStatus === 'loading' ? 'Loading...' :
|
||||
systemStatus === 'disconnected' ? 'Connecting...' :
|
||||
systemStatus === 'initializing' ? 'Initializing...' :
|
||||
systemStatus === 'error' ? 'System Error' : 'System Idle';
|
||||
|
||||
const systemStatusDot =
|
||||
systemStatus === 'ready' ? 'bg-sage-400' :
|
||||
systemStatus === 'loading' ? 'bg-amber-400 animate-pulse' :
|
||||
systemStatus === 'disconnected' ? 'bg-amber-400 animate-pulse' :
|
||||
systemStatus === 'initializing' ? 'bg-amber-400 animate-pulse' :
|
||||
systemStatus === 'error' ? 'bg-coral-400' : 'bg-stone-600';
|
||||
|
||||
return (
|
||||
<div className="min-h-full relative">
|
||||
<div className="relative z-10 min-h-full flex flex-col items-center justify-center">
|
||||
<div className="max-w-md mx-auto text-center px-6">
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl bg-white/[0.05] border border-white/[0.08] flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
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>
|
||||
</div>
|
||||
<div className="relative z-10 min-h-full flex flex-col">
|
||||
<div className="flex-1 p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Intelligence</h1>
|
||||
{stats.total > 0 && (
|
||||
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
|
||||
{stats.total}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${systemStatusDot}`} />
|
||||
<span className="text-xs text-stone-400">{systemStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Intelligence</h1>
|
||||
<p className="text-stone-400 text-sm mb-6">
|
||||
AI-powered insights, memory, and analytics are coming soon.
|
||||
</p>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-6 animate-fade-up">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actionable items..."
|
||||
value={filters.search}
|
||||
onChange={e => dispatch(setSearchFilter(e.target.value))}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.source}
|
||||
onChange={e => dispatch(setSourceFilter(e.target.value as any))}
|
||||
className="px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500/50 transition-colors">
|
||||
<option value="all">All Sources</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="calendar">Calendar</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="ai_insight">AI Insights</option>
|
||||
<option value="system">System</option>
|
||||
<option value="trading">Trading</option>
|
||||
<option value="security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary-500/10 border border-primary-500/20">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-400 animate-pulse" />
|
||||
<span className="text-xs font-medium text-primary-400">Coming Soon</span>
|
||||
{/* Content */}
|
||||
{itemsLoading ? (
|
||||
/* Loading State */
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Loading Intelligence...</h2>
|
||||
<p className="text-stone-400 text-sm">Fetching your actionable items</p>
|
||||
</div>
|
||||
) : itemsError ? (
|
||||
/* Error State */
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-coral-500/10">
|
||||
<svg
|
||||
className="w-8 h-8 text-coral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Unable to load items</h2>
|
||||
<p className="text-stone-400 text-sm mb-4">
|
||||
{typeof itemsError === 'string' ? itemsError : 'Something went wrong'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchItems()}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : timeGroups.length === 0 ? (
|
||||
/* Empty State */
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">All caught up!</h2>
|
||||
<p className="text-stone-400 text-sm">
|
||||
{filters.search || filters.source !== 'all'
|
||||
? 'No items match your current filters.'
|
||||
: 'No actionable items at the moment. Great work!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Time Groups */
|
||||
<div className="space-y-6">
|
||||
{timeGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.label}
|
||||
className="animate-fade-up"
|
||||
style={{ animationDelay: `${groupIndex * 50}ms` }}>
|
||||
{/* Group Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-white opacity-80">{group.label}</h2>
|
||||
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
|
||||
{group.count}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-3">
|
||||
{group.items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="animate-fade-up"
|
||||
style={{ animationDelay: `${groupIndex * 50 + itemIndex * 25}ms` }}>
|
||||
<ActionableCard
|
||||
item={item}
|
||||
onComplete={handleComplete}
|
||||
onDismiss={handleDismiss}
|
||||
onSnooze={handleSnooze}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||
|
||||
{/* Confirmation modal */}
|
||||
<ConfirmationModal
|
||||
modal={confirmationModal}
|
||||
onClose={() => setConfirmationModal(prev => ({ ...prev, isOpen: false }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
|
||||
import AdvancedPanel from '../components/settings/panels/AdvancedPanel';
|
||||
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
|
||||
import AIPanel from '../components/settings/panels/AIPanel';
|
||||
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
|
||||
import BillingPanel from '../components/settings/panels/BillingPanel';
|
||||
import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel';
|
||||
import MessagingPanel from '../components/settings/panels/MessagingPanel';
|
||||
@@ -37,6 +38,7 @@ const Settings = () => {
|
||||
<Route path="team/manage/:teamId/invites" element={<TeamInvitesPanel />} />
|
||||
<Route path="team/members" element={<TeamMembersPanel />} />
|
||||
<Route path="team/invites" element={<TeamInvitesPanel />} />
|
||||
<Route path="developer-options" element={<DeveloperOptionsPanel />} />
|
||||
<Route path="tauri-commands" element={<TauriCommandsPanel />} />
|
||||
<Route path="memory-debug" element={<MemoryDebugPanel />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useContext, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { useIntelligenceSocketManager } from '../hooks/useIntelligenceSocket';
|
||||
import { setInitialized, setConnectionStatus } from '../store/intelligenceSlice';
|
||||
|
||||
/**
|
||||
* Intelligence context for managing system-wide Intelligence state
|
||||
*/
|
||||
interface IntelligenceContextValue {
|
||||
isInitialized: boolean;
|
||||
isConnected: boolean;
|
||||
initialize: () => void;
|
||||
}
|
||||
|
||||
const IntelligenceContext = createContext<IntelligenceContextValue | null>(null);
|
||||
|
||||
interface IntelligenceProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligence Provider - manages Intelligence system initialization and state
|
||||
*/
|
||||
export function IntelligenceProvider({ children }: IntelligenceProviderProps) {
|
||||
const dispatch = useDispatch();
|
||||
const socketManager = useIntelligenceSocketManager();
|
||||
|
||||
// Initialize Intelligence system
|
||||
useEffect(() => {
|
||||
dispatch(setInitialized(true));
|
||||
dispatch(setConnectionStatus(socketManager.isConnected ? 'connected' : 'connecting'));
|
||||
}, [dispatch, socketManager.isConnected]);
|
||||
|
||||
// Monitor connection status
|
||||
useEffect(() => {
|
||||
if (socketManager.isConnected) {
|
||||
dispatch(setConnectionStatus('connected'));
|
||||
} else {
|
||||
dispatch(setConnectionStatus('connecting'));
|
||||
}
|
||||
}, [dispatch, socketManager.isConnected]);
|
||||
|
||||
const contextValue: IntelligenceContextValue = {
|
||||
isInitialized: true,
|
||||
isConnected: socketManager.isConnected,
|
||||
initialize: socketManager.connect,
|
||||
};
|
||||
|
||||
return (
|
||||
<IntelligenceContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</IntelligenceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access Intelligence context
|
||||
*/
|
||||
export function useIntelligenceContext() {
|
||||
const context = useContext(IntelligenceContext);
|
||||
if (!context) {
|
||||
throw new Error('useIntelligenceContext must be used within IntelligenceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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, any>;
|
||||
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?: any[];
|
||||
result?: any;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get(`/executions/${executionId}/status`);
|
||||
return response as {
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
progress?: any[];
|
||||
result?: any;
|
||||
};
|
||||
} 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();
|
||||
@@ -19,6 +19,7 @@ import aiReducer from './aiSlice';
|
||||
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
|
||||
import daemonReducer from './daemonSlice';
|
||||
import gmailReducer from './gmailSlice';
|
||||
import intelligenceReducer from './intelligenceSlice';
|
||||
import inviteReducer from './inviteSlice';
|
||||
import notionReducer from './notionSlice';
|
||||
import skillsReducer from './skillsSlice';
|
||||
@@ -104,6 +105,7 @@ export const store = configureStore({
|
||||
gmail: gmailReducer,
|
||||
team: teamReducer,
|
||||
thread: persistedThreadReducer,
|
||||
intelligence: intelligenceReducer,
|
||||
invite: inviteReducer,
|
||||
notion: notionReducer,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { intelligenceApi } from '../services/intelligenceApi';
|
||||
import type {
|
||||
ActionableItem,
|
||||
ActionableItemSource,
|
||||
ActionableItemStatus,
|
||||
ChatMessage
|
||||
} from '../types/intelligence';
|
||||
import { transformBackendItemsToFrontend, transformBackendMessagesToFrontend } from '../utils/intelligenceTransforms';
|
||||
|
||||
/**
|
||||
* Chat session state for managing individual conversations
|
||||
*/
|
||||
export interface ChatSessionState {
|
||||
threadId: string;
|
||||
itemId: string;
|
||||
messages: ChatMessage[];
|
||||
isTyping: boolean;
|
||||
lastMessageId?: string;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution state for tracking task progress
|
||||
*/
|
||||
export interface ExecutionState {
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
itemId: string;
|
||||
status: 'idle' | 'starting' | 'running' | 'completed' | 'failed';
|
||||
progress: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
timestamp?: Date;
|
||||
}>;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligence Redux state
|
||||
*/
|
||||
export interface IntelligenceState {
|
||||
// Actionable Items
|
||||
items: ActionableItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
|
||||
// Chat Sessions
|
||||
activeSessions: Record<string, ChatSessionState>;
|
||||
currentChatSession: string | null;
|
||||
|
||||
// Execution Management
|
||||
activeExecutions: Record<string, ExecutionState>;
|
||||
|
||||
// UI State
|
||||
filters: {
|
||||
source: ActionableItemSource | 'all';
|
||||
priority: 'critical' | 'important' | 'normal' | 'all';
|
||||
search: string;
|
||||
};
|
||||
|
||||
// System state
|
||||
initialized: boolean;
|
||||
connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
}
|
||||
|
||||
const initialState: IntelligenceState = {
|
||||
// Actionable Items
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdate: null,
|
||||
|
||||
// Chat Sessions
|
||||
activeSessions: {},
|
||||
currentChatSession: null,
|
||||
|
||||
// Execution Management
|
||||
activeExecutions: {},
|
||||
|
||||
// UI State
|
||||
filters: {
|
||||
source: 'all',
|
||||
priority: 'all',
|
||||
search: '',
|
||||
},
|
||||
|
||||
// System state
|
||||
initialized: false,
|
||||
connectionStatus: 'disconnected',
|
||||
};
|
||||
|
||||
// Async thunks for API operations
|
||||
|
||||
/**
|
||||
* Fetch actionable items from backend
|
||||
*/
|
||||
export const fetchActionableItems = createAsyncThunk(
|
||||
'intelligence/fetchActionableItems',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const backendItems = await intelligenceApi.getActionableItems();
|
||||
return transformBackendItemsToFrontend(backendItems);
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to fetch actionable items'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update item status
|
||||
*/
|
||||
export const updateItemStatus = createAsyncThunk(
|
||||
'intelligence/updateItemStatus',
|
||||
async (
|
||||
{ itemId, status }: { itemId: string; status: ActionableItemStatus },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
await intelligenceApi.updateItemStatus(itemId, status);
|
||||
return { itemId, status, updatedAt: new Date() };
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to update item status'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Snooze item until specific time
|
||||
*/
|
||||
export const snoozeItem = createAsyncThunk(
|
||||
'intelligence/snoozeItem',
|
||||
async (
|
||||
{ itemId, snoozeUntil }: { itemId: string; snoozeUntil: Date },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
await intelligenceApi.snoozeItem(itemId, snoozeUntil);
|
||||
return { itemId, snoozeUntil, updatedAt: new Date() };
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to snooze item'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get or create chat session
|
||||
*/
|
||||
export const createChatSession = createAsyncThunk(
|
||||
'intelligence/createChatSession',
|
||||
async ({ itemId }: { itemId: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const threadResponse = await intelligenceApi.getOrCreateThread(itemId);
|
||||
const messages = transformBackendMessagesToFrontend(threadResponse.messages);
|
||||
|
||||
return {
|
||||
threadId: threadResponse.threadId,
|
||||
itemId,
|
||||
messages,
|
||||
};
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to create chat session'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Execute task with connected tools
|
||||
*/
|
||||
export const executeTask = createAsyncThunk(
|
||||
'intelligence/executeTask',
|
||||
async (
|
||||
{ itemId, connectedTools }: { itemId: string; connectedTools: any[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await intelligenceApi.executeTask(itemId, connectedTools);
|
||||
return {
|
||||
executionId: response.executionId,
|
||||
sessionId: response.sessionId,
|
||||
itemId,
|
||||
status: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to execute task'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Intelligence slice
|
||||
*/
|
||||
export const intelligenceSlice = createSlice({
|
||||
name: 'intelligence',
|
||||
initialState,
|
||||
reducers: {
|
||||
// System actions
|
||||
setInitialized: (state, action: PayloadAction<boolean>) => {
|
||||
state.initialized = action.payload;
|
||||
},
|
||||
|
||||
setConnectionStatus: (
|
||||
state,
|
||||
action: PayloadAction<'disconnected' | 'connecting' | 'connected' | 'error'>
|
||||
) => {
|
||||
state.connectionStatus = action.payload;
|
||||
},
|
||||
|
||||
// Items actions
|
||||
setItems: (state, action: PayloadAction<ActionableItem[]>) => {
|
||||
state.items = action.payload;
|
||||
state.lastUpdate = new Date();
|
||||
},
|
||||
|
||||
addItem: (state, action: PayloadAction<ActionableItem>) => {
|
||||
state.items.unshift(action.payload);
|
||||
state.lastUpdate = new Date();
|
||||
},
|
||||
|
||||
removeItem: (state, action: PayloadAction<string>) => {
|
||||
state.items = state.items.filter(item => item.id !== action.payload);
|
||||
state.lastUpdate = new Date();
|
||||
},
|
||||
|
||||
// Filters actions
|
||||
setSourceFilter: (state, action: PayloadAction<ActionableItemSource | 'all'>) => {
|
||||
state.filters.source = action.payload;
|
||||
},
|
||||
|
||||
setPriorityFilter: (state, action: PayloadAction<'critical' | 'important' | 'normal' | 'all'>) => {
|
||||
state.filters.priority = action.payload;
|
||||
},
|
||||
|
||||
setSearchFilter: (state, action: PayloadAction<string>) => {
|
||||
state.filters.search = action.payload;
|
||||
},
|
||||
|
||||
// Chat session actions
|
||||
setChatSession: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
itemId: string;
|
||||
messages?: ChatMessage[];
|
||||
}>
|
||||
) => {
|
||||
const { threadId, itemId, messages = [] } = action.payload;
|
||||
state.activeSessions[threadId] = {
|
||||
threadId,
|
||||
itemId,
|
||||
messages,
|
||||
isTyping: false,
|
||||
isConnected: true,
|
||||
};
|
||||
state.currentChatSession = threadId;
|
||||
},
|
||||
|
||||
addMessage: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
message: ChatMessage;
|
||||
}>
|
||||
) => {
|
||||
const { threadId, message } = action.payload;
|
||||
const session = state.activeSessions[threadId];
|
||||
if (session) {
|
||||
session.messages.push(message);
|
||||
session.lastMessageId = message.id;
|
||||
}
|
||||
},
|
||||
|
||||
setTyping: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
isTyping: boolean;
|
||||
}>
|
||||
) => {
|
||||
const { threadId, isTyping } = action.payload;
|
||||
const session = state.activeSessions[threadId];
|
||||
if (session) {
|
||||
session.isTyping = isTyping;
|
||||
}
|
||||
},
|
||||
|
||||
closeChatSession: (state, action: PayloadAction<string>) => {
|
||||
const threadId = action.payload;
|
||||
delete state.activeSessions[threadId];
|
||||
if (state.currentChatSession === threadId) {
|
||||
state.currentChatSession = null;
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentChatSession: (state, action: PayloadAction<string | null>) => {
|
||||
state.currentChatSession = action.payload;
|
||||
},
|
||||
|
||||
// Execution actions
|
||||
setExecution: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
executionId: string;
|
||||
execution: ExecutionState;
|
||||
}>
|
||||
) => {
|
||||
const { executionId, execution } = action.payload;
|
||||
state.activeExecutions[executionId] = execution;
|
||||
},
|
||||
|
||||
updateExecutionProgress: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
executionId: string;
|
||||
progress: ExecutionState['progress'];
|
||||
}>
|
||||
) => {
|
||||
const { executionId, progress } = action.payload;
|
||||
const execution = state.activeExecutions[executionId];
|
||||
if (execution) {
|
||||
execution.progress = progress;
|
||||
execution.status = 'running';
|
||||
}
|
||||
},
|
||||
|
||||
setExecutionResult: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
executionId: string;
|
||||
result: any;
|
||||
status: 'completed' | 'failed';
|
||||
error?: string;
|
||||
}>
|
||||
) => {
|
||||
const { executionId, result, status, error } = action.payload;
|
||||
const execution = state.activeExecutions[executionId];
|
||||
if (execution) {
|
||||
execution.result = result;
|
||||
execution.status = status;
|
||||
execution.error = error;
|
||||
}
|
||||
},
|
||||
|
||||
clearExecution: (state, action: PayloadAction<string>) => {
|
||||
const executionId = action.payload;
|
||||
delete state.activeExecutions[executionId];
|
||||
},
|
||||
|
||||
// Error handling
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
// Fetch actionable items
|
||||
builder
|
||||
.addCase(fetchActionableItems.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchActionableItems.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.items = action.payload;
|
||||
state.lastUpdate = new Date();
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchActionableItems.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Update item status
|
||||
builder
|
||||
.addCase(updateItemStatus.fulfilled, (state, action) => {
|
||||
const { itemId, status, updatedAt } = action.payload;
|
||||
const item = state.items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
item.status = status;
|
||||
item.updatedAt = updatedAt;
|
||||
|
||||
// Set completion/dismissal timestamps
|
||||
if (status === 'completed') {
|
||||
item.completedAt = updatedAt;
|
||||
} else if (status === 'dismissed') {
|
||||
item.dismissedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
state.lastUpdate = new Date();
|
||||
})
|
||||
.addCase(updateItemStatus.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Snooze item
|
||||
builder
|
||||
.addCase(snoozeItem.fulfilled, (state, action) => {
|
||||
const { itemId, snoozeUntil, updatedAt } = action.payload;
|
||||
const item = state.items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
item.status = 'snoozed';
|
||||
item.snoozeUntil = snoozeUntil;
|
||||
item.updatedAt = updatedAt;
|
||||
item.reminderCount = (item.reminderCount || 0) + 1;
|
||||
}
|
||||
state.lastUpdate = new Date();
|
||||
})
|
||||
.addCase(snoozeItem.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Create chat session
|
||||
builder
|
||||
.addCase(createChatSession.fulfilled, (state, action) => {
|
||||
const { threadId, itemId, messages } = action.payload;
|
||||
state.activeSessions[threadId] = {
|
||||
threadId,
|
||||
itemId,
|
||||
messages,
|
||||
isTyping: false,
|
||||
isConnected: true,
|
||||
};
|
||||
state.currentChatSession = threadId;
|
||||
|
||||
// Update item with thread information
|
||||
const item = state.items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
item.threadId = threadId;
|
||||
}
|
||||
})
|
||||
.addCase(createChatSession.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Execute task
|
||||
builder
|
||||
.addCase(executeTask.fulfilled, (state, action) => {
|
||||
const { executionId, sessionId, itemId, status } = action.payload;
|
||||
state.activeExecutions[executionId] = {
|
||||
executionId,
|
||||
sessionId,
|
||||
itemId,
|
||||
status: status === 'started' ? 'running' : 'idle',
|
||||
progress: [],
|
||||
};
|
||||
|
||||
// Update item execution status
|
||||
const item = state.items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
item.executionStatus = 'running';
|
||||
item.currentSessionId = sessionId;
|
||||
}
|
||||
})
|
||||
.addCase(executeTask.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setInitialized,
|
||||
setConnectionStatus,
|
||||
setItems,
|
||||
addItem,
|
||||
removeItem,
|
||||
setSourceFilter,
|
||||
setPriorityFilter,
|
||||
setSearchFilter,
|
||||
setChatSession,
|
||||
addMessage,
|
||||
setTyping,
|
||||
closeChatSession,
|
||||
setCurrentChatSession,
|
||||
setExecution,
|
||||
updateExecutionProgress,
|
||||
setExecutionResult,
|
||||
clearExecution,
|
||||
clearError,
|
||||
} = intelligenceSlice.actions;
|
||||
|
||||
export default intelligenceSlice.reducer;
|
||||
@@ -114,11 +114,13 @@ export const sendMessage = createAsyncThunk(
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Remove optimistic user message on failure
|
||||
const state = (getState() as { thread: ThreadState }).thread;
|
||||
const messages = state.messagesByThreadId[threadId] || [];
|
||||
const filteredMessages = messages.filter(m => m.id !== userMessage.id);
|
||||
dispatch(updateMessagesForThread({ threadId, messages: filteredMessages }));
|
||||
// Add an error message as an agent response so the conversation flow continues
|
||||
dispatch(
|
||||
addInferenceResponse({
|
||||
content: 'Something went wrong — please try again.',
|
||||
threadId,
|
||||
})
|
||||
);
|
||||
|
||||
const msg =
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 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, any> };
|
||||
}
|
||||
|
||||
export interface CreateChatSessionResponse {
|
||||
success: true;
|
||||
data: {
|
||||
session_id: string;
|
||||
initial_message: {
|
||||
content: string;
|
||||
type: MessageType;
|
||||
options: string[];
|
||||
context: Record<string, any>;
|
||||
};
|
||||
expected_flow: ConversationFlow[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
sender: 'ai' | 'user';
|
||||
timestamp: string; // ISO 8601
|
||||
type: MessageType;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ChatSessionDetails {
|
||||
session_id: string;
|
||||
status: SessionStatus;
|
||||
current_flow: ConversationFlow;
|
||||
messages: ChatMessage[];
|
||||
context: Record<string, any>;
|
||||
execution_id?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
content: string;
|
||||
sender: 'user';
|
||||
current_flow: ConversationFlow;
|
||||
context: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
success: true;
|
||||
data: {
|
||||
ai_response: {
|
||||
content: string;
|
||||
type: MessageType;
|
||||
next_flow: ConversationFlow;
|
||||
options: string[];
|
||||
metadata: Record<string, any>;
|
||||
};
|
||||
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, any>;
|
||||
}
|
||||
|
||||
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, any>;
|
||||
}
|
||||
|
||||
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, any>;
|
||||
}
|
||||
|
||||
// ===== WebSocket Event Types =====
|
||||
|
||||
export interface WebSocketMessage<T = any> {
|
||||
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, any>; 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, any>;
|
||||
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, any>;
|
||||
}
|
||||
|
||||
// ===== Standard API Response Envelope =====
|
||||
|
||||
export interface APIResponse<T = any> {
|
||||
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, any>;
|
||||
}
|
||||
|
||||
// ===== 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: any): obj is WebSocketMessage<T> {
|
||||
return typeof obj === 'object' && obj !== null && typeof obj.type === 'string' && 'data' in obj;
|
||||
}
|
||||
|
||||
export function isAPIResponse<T>(obj: any): obj is APIResponse<T> {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
typeof obj.success === 'boolean' &&
|
||||
('data' in obj || 'error' in obj) &&
|
||||
'meta' in obj
|
||||
);
|
||||
}
|
||||
|
||||
export function isExecutionStepProgressEvent(obj: any): obj is ExecutionStepProgressEvent {
|
||||
return (
|
||||
isWebSocketMessage(obj) &&
|
||||
obj.type === 'execution_step_progress' &&
|
||||
typeof obj.data === 'object' &&
|
||||
obj.data !== null &&
|
||||
'step_id' in obj.data &&
|
||||
'progress_percentage' in obj.data
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Utility Types =====
|
||||
|
||||
export type ChatEventHandler<T = any> = (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;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Intelligence System Types
|
||||
// Actionable items and AI insights for the Intelligence page
|
||||
import type React from 'react';
|
||||
|
||||
export type ActionableItemSource =
|
||||
| 'email'
|
||||
| 'calendar'
|
||||
| 'telegram'
|
||||
| 'ai_insight'
|
||||
| 'system'
|
||||
| 'trading'
|
||||
| 'security';
|
||||
|
||||
export type ActionableItemPriority = 'critical' | 'important' | 'normal';
|
||||
|
||||
export type ActionableItemStatus = 'active' | 'dismissed' | 'completed' | 'snoozed';
|
||||
|
||||
export interface ActionableItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
source: ActionableItemSource;
|
||||
priority: ActionableItemPriority;
|
||||
status: ActionableItemStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
expiresAt?: Date;
|
||||
snoozeUntil?: Date;
|
||||
|
||||
// Action metadata
|
||||
actionable: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
hasComplexAction?: boolean;
|
||||
|
||||
// Visual presentation
|
||||
icon?: React.ReactElement;
|
||||
sourceLabel?: string;
|
||||
|
||||
// Interaction tracking
|
||||
dismissedAt?: Date;
|
||||
completedAt?: Date;
|
||||
reminderCount?: number;
|
||||
|
||||
// Backend integration fields
|
||||
threadId?: string;
|
||||
executionStatus?: 'idle' | 'running' | 'completed' | 'failed';
|
||||
currentSessionId?: string;
|
||||
}
|
||||
|
||||
export interface ActionableItemAction {
|
||||
type: 'complete' | 'dismiss' | 'snooze';
|
||||
timestamp: Date;
|
||||
itemId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TimeGroup {
|
||||
label: string;
|
||||
items: ActionableItem[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface IntelligencePageState {
|
||||
items: ActionableItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
|
||||
// UI state
|
||||
showCompleted: boolean;
|
||||
filter: ActionableItemSource | 'all';
|
||||
}
|
||||
|
||||
// Snooze time options
|
||||
export type SnoozeOption = {
|
||||
label: string;
|
||||
duration: number; // milliseconds
|
||||
customTime?: Date;
|
||||
};
|
||||
|
||||
// Toast notification types
|
||||
export interface ToastNotification {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
action?: { label: string; handler: () => void };
|
||||
}
|
||||
|
||||
// Confirmation modal data
|
||||
export interface ConfirmationModal {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
destructive?: boolean;
|
||||
showDontShowAgain?: boolean;
|
||||
}
|
||||
|
||||
// Chat message type
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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 || {},
|
||||
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: any): item is BackendActionableItem {
|
||||
return (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
typeof item.id === 'string' &&
|
||||
typeof item.title === 'string' &&
|
||||
typeof item.source === 'string' &&
|
||||
typeof item.priority === 'string' &&
|
||||
typeof item.status === 'string' &&
|
||||
typeof item.createdAt === 'string' &&
|
||||
typeof item.updatedAt === 'string' &&
|
||||
typeof item.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',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user