diff --git a/docs/MVP.md b/docs/MVP.md deleted file mode 100644 index 920a439c7..000000000 --- a/docs/MVP.md +++ /dev/null @@ -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 diff --git a/docs/feat/daemon-lifecycle-management.md b/docs/feat/daemon-lifecycle-management.md deleted file mode 100644 index 4e102c4e4..000000000 --- a/docs/feat/daemon-lifecycle-management.md +++ /dev/null @@ -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; - 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 - run(alphahumanServiceStatus, 'serviceStatus')}> - Status - - -// After: Live status display -
- -
-
Daemon Status: {status}
-
PID: {pid} | Uptime: {uptime}
-
-
-``` - -#### 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. diff --git a/docs/gmail-skill-integration.md b/docs/gmail-skill-integration.md deleted file mode 100644 index 36614742f..000000000 --- a/docs/gmail-skill-integration.md +++ /dev/null @@ -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//`. 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` 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= - &redirect_uri=/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= - &code_challenge_method=S256 - &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=&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= - client_id= - client_secret= - redirect_uri=/auth/callback/gmail - code_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 -``` - -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:` -- **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= - client_id= - 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 -``` - -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 -``` - -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_` | -| `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_` and the summary/metadata as -`gmail_msg_`. - -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 - │ Content-Type: application/json - │ Body: - │ { "raw": "" } - └── Return ToolResult { success, output: "Message sent. 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": "", "threadId": "18de7f2a1b3c4e5d" } -``` - -The RFC 2822 message must include `In-Reply-To: ` and -`References: ` 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 /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_, category=gmail - │ 15. memory_store gmail_sync_history_id = - │ - ▼ -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:///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. diff --git a/docs/teams-api-reference.md b/docs/teams-api-reference.md deleted file mode 100644 index 9c194df28..000000000 --- a/docs/teams-api-reference.md +++ /dev/null @@ -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 -``` - -### Rate Limiting - -Standard API rate limits apply to all endpoints. Refer to main API documentation for specific limits. diff --git a/docs/the-tet.png b/docs/the-tet.png deleted file mode 100644 index cb5ace124..000000000 Binary files a/docs/the-tet.png and /dev/null differ diff --git a/src/components/intelligence/ActionableCard.tsx b/src/components/intelligence/ActionableCard.tsx new file mode 100644 index 000000000..0ec0dfcf3 --- /dev/null +++ b/src/components/intelligence/ActionableCard.tsx @@ -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; + onClose: () => void; + onSnooze: (duration: number) => void; +} + +function SnoozeDropdownPortal({ isOpen, buttonRef, onClose, onSnooze }: SnoozeDropdownPortalProps) { + const [position, setPosition] = useState({ top: 0, left: 0 }); + const dropdownRef = useRef(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( +
+ {SNOOZE_OPTIONS.map(option => ( + + ))} +
, + document.body + ); +} + +// Source icons for different actionable item types +const SOURCE_ICONS = { + email: ( + + + + ), + calendar: ( + + + + ), + telegram: ( + + + + ), + ai_insight: ( + + + + ), + system: ( + + + + ), + trading: ( + + + + ), + security: ( + + + + ), +}; + +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(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 ( +
+
+ {/* Main content row */} +
+ {/* Icon */} +
+ {sourceIcon} +
+ + {/* Content */} +
+
+
+

{item.title}

+ {item.description && ( +

{item.description}

+ )} +
+ + {/* Action buttons */} +
+ {/* Complete button */} + + + {/* Dismiss button */} + + + {/* Snooze button */} +
+ +
+
+
+ + {/* Meta info */} +
+
+
+ {item.sourceLabel || item.source} +
+ + {timeAgo} + {isNew && ( + <> + + + New + + + )} +
+
+
+
+ + {/* Snooze dropdown portal - renders outside of any stacking context */} + setShowSnoozeMenu(false)} + onSnooze={handleSnooze} + /> +
+ ); +} diff --git a/src/components/intelligence/ConfirmationModal.tsx b/src/components/intelligence/ConfirmationModal.tsx new file mode 100644 index 000000000..361a2a07c --- /dev/null +++ b/src/components/intelligence/ConfirmationModal.tsx @@ -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 ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+ {modal.destructive && ( +
+ + + +
+ )} +
+

{modal.title}

+

{modal.message}

+
+
+
+ + {/* Don't show again option */} + {modal.showDontShowAgain && ( +
+ +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ); +} diff --git a/src/components/intelligence/Toast.tsx b/src/components/intelligence/Toast.tsx new file mode 100644 index 000000000..b506474a1 --- /dev/null +++ b/src/components/intelligence/Toast.tsx @@ -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: ( + + + + ), + error: ( + + + + ), + warning: ( + + + + ), + info: ( + + + + ), +}; + +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 ( +
+
+ {/* Icon */} +
{icon}
+ + {/* Content */} +
+

{notification.title}

+ {notification.message && ( +

{notification.message}

+ )} +
+ + {/* Action button */} + {notification.action && ( + + )} + + {/* Close button */} + +
+
+ ); +} + +interface ToastContainerProps { + notifications: ToastNotification[]; + onRemove: (id: string) => void; +} + +export function ToastContainer({ notifications, onRemove }: ToastContainerProps) { + if (notifications.length === 0) return null; + + return ( +
+
+ {notifications.map(notification => ( + + ))} +
+
+ ); +} diff --git a/src/components/intelligence/index.ts b/src/components/intelligence/index.ts new file mode 100644 index 000000000..58b34fc59 --- /dev/null +++ b/src/components/intelligence/index.ts @@ -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'; diff --git a/src/components/intelligence/mockData.ts b/src/components/intelligence/mockData.ts new file mode 100644 index 000000000..739222102 --- /dev/null +++ b/src/components/intelligence/mockData.ts @@ -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', + }, +]; diff --git a/src/components/intelligence/utils.ts b/src/components/intelligence/utils.ts new file mode 100644 index 000000000..0ae4b9fa3 --- /dev/null +++ b/src/components/intelligence/utils.ts @@ -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 = { + 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 + ); + + 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 }; +} diff --git a/src/components/settings/SettingsHome.tsx b/src/components/settings/SettingsHome.tsx index a78daea97..1124920b9 100644 --- a/src/components/settings/SettingsHome.tsx +++ b/src/components/settings/SettingsHome.tsx @@ -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: ( - - - - ), - onClick: () => navigateToSettings('skills'), - dangerous: false, - }, - { - id: 'ai', - title: 'AI Configuration', - description: 'Configure SOUL persona and AI behavior', - devOnly: true, - icon: ( - - - - ), - onClick: () => navigateToSettings('ai'), - dangerous: false, - }, // { // id: 'agent-chat', // title: 'Agent Chat', // description: 'Send messages directly to your agent', - // devOnly: true, // icon: ( // // { 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: ( ), - onClick: () => navigateToSettings('tauri-commands'), - dangerous: false, - }, - { - id: 'memory-debug', - title: 'Memory Debug', - description: 'Inspect memory documents, namespaces, and test query/recall', - devOnly: true, - icon: ( - - - - ), - onClick: () => navigateToSettings('memory-debug'), + onClick: () => navigateToSettings('developer-options'), dangerous: false, }, ]; @@ -359,9 +302,7 @@ const SettingsHome = () => {
{/* Main Settings */}
- {mainMenuItems - .filter(item => !item.devOnly || IS_DEV) - .map((item, index, filtered) => ( + {mainMenuItems.map((item, index) => ( { onClick={item.onClick} dangerous={item.dangerous} isFirst={index === 0} - isLast={index === filtered.length - 1} + isLast={index === mainMenuItems.length - 1} /> ))}
diff --git a/src/components/settings/hooks/useSettingsNavigation.ts b/src/components/settings/hooks/useSettingsNavigation.ts index 314d641bb..07f02797c 100644 --- a/src/components/settings/hooks/useSettingsNavigation.ts +++ b/src/components/settings/hooks/useSettingsNavigation.ts @@ -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'); } diff --git a/src/components/settings/panels/DeveloperOptionsPanel.tsx b/src/components/settings/panels/DeveloperOptionsPanel.tsx new file mode 100644 index 000000000..bb404f7d3 --- /dev/null +++ b/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -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: ( + + + + ), + }, + { + id: 'ai', + title: 'AI Configuration', + description: 'Configure SOUL persona and AI behavior', + route: 'ai', + icon: ( + + + + ), + }, + { + id: 'tauri-commands', + title: 'Tauri Command Console', + description: 'Run Alphahuman Tauri commands for quick testing', + route: 'tauri-commands', + icon: ( + + + + ), + }, + { + id: 'memory-debug', + title: 'Memory Debug', + description: 'Inspect memory documents, namespaces, and test query/recall', + route: 'memory-debug', + icon: ( + + + + ), + }, +]; + +const DeveloperOptionsPanel = () => { + const { navigateToSettings, navigateBack } = useSettingsNavigation(); + + return ( +
+ + +
+
+
+ {developerItems.map((item, index) => ( + navigateToSettings(item.route)} + isFirst={index === 0} + isLast={index === developerItems.length - 1} + /> + ))} +
+
+
+
+ ); +}; + +export default DeveloperOptionsPanel; diff --git a/src/components/settings/panels/MemoryDebugPanel.tsx b/src/components/settings/panels/MemoryDebugPanel.tsx index d6bd45922..fdd857d6c 100644 --- a/src/components/settings/panels/MemoryDebugPanel.tsx +++ b/src/components/settings/panels/MemoryDebugPanel.tsx @@ -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 ( -
- -
- Memory debug is available only in development mode. -
-
- ); - } - return (
diff --git a/src/hooks/useIntelligenceApiFallback.ts b/src/hooks/useIntelligenceApiFallback.ts new file mode 100644 index 000000000..ffbc31f9e --- /dev/null +++ b/src/hooks/useIntelligenceApiFallback.ts @@ -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; +} + +/** + * 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(MOCK_ACTIONABLE_ITEMS); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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(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(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(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(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, +}; \ No newline at end of file diff --git a/src/hooks/useIntelligenceSocket.ts b/src/hooks/useIntelligenceSocket.ts new file mode 100644 index 000000000..43f16926d --- /dev/null +++ b/src/hooks/useIntelligenceSocket.ts @@ -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; +} + +interface AgentResponsePayload { + message: string; + threadId: string; + sessionId?: string; + shouldExecute?: boolean; + executionPlan?: any; + metadata?: Record; +} + +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; +} + +/** + * 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, + }; +}; \ No newline at end of file diff --git a/src/lib/intelligence/chatTools.ts b/src/lib/intelligence/chatTools.ts new file mode 100644 index 000000000..9b58aeb0d --- /dev/null +++ b/src/lib/intelligence/chatTools.ts @@ -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 = {}; + 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 []; + } +} diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 9dfeec93d..aabe07a57 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -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 }; - }; - 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 }; - }; - 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); diff --git a/src/pages/Intelligence.tsx b/src/pages/Intelligence.tsx index de78c4509..1c353ab79 100644 --- a/src/pages/Intelligence.tsx +++ b/src/pages/Intelligence.tsx @@ -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([]); + const [confirmationModal, setConfirmationModal] = useState({ + 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) => { + 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 (
-
-
- {/* Icon */} -
- - - -
+
+
+
+ {/* Header */} +
+
+

Intelligence

+ {stats.total > 0 && ( +
+ {stats.total} +
+ )} +
+
+
+ {systemStatusLabel} +
+
-

Intelligence

-

- AI-powered insights, memory, and analytics are coming soon. -

+ {/* Filters */} +
+
+ 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" + /> +
+ +
-
-
- Coming Soon + {/* Content */} + {itemsLoading ? ( + /* Loading State */ +
+
+
+
+

Loading Intelligence...

+

Fetching your actionable items

+
+ ) : itemsError ? ( + /* Error State */ +
+
+ + + +
+

Unable to load items

+

+ {typeof itemsError === 'string' ? itemsError : 'Something went wrong'} +

+ +
+ ) : timeGroups.length === 0 ? ( + /* Empty State */ +
+
+ + + +
+

All caught up!

+

+ {filters.search || filters.source !== 'all' + ? 'No items match your current filters.' + : 'No actionable items at the moment. Great work!'} +

+
+ ) : ( + /* Time Groups */ +
+ {timeGroups.map((group, groupIndex) => ( +
+ {/* Group Header */} +
+

{group.label}

+
+ {group.count} +
+
+ + {/* Items */} +
+ {group.items.map((item, itemIndex) => ( +
+ +
+ ))} +
+
+ ))} +
+ )}
+ + {/* Toast notifications */} + + + {/* Confirmation modal */} + setConfirmationModal(prev => ({ ...prev, isOpen: false }))} + />
); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index beeaf203f..12a4e9574 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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 = () => { } /> } /> } /> + } /> } /> } /> diff --git a/src/providers/IntelligenceProvider.tsx b/src/providers/IntelligenceProvider.tsx new file mode 100644 index 000000000..ea4c54ebf --- /dev/null +++ b/src/providers/IntelligenceProvider.tsx @@ -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(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 ( + + {children} + + ); +} + +/** + * 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; +} \ No newline at end of file diff --git a/src/services/intelligenceApi.ts b/src/services/intelligenceApi.ts new file mode 100644 index 000000000..2d163d7dd --- /dev/null +++ b/src/services/intelligenceApi.ts @@ -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; + 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 { + 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 { + 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 { + 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 { + try { + const response = await apiClient.get(`/${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 { + 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 { + try { + const response = await apiClient.post(`/${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 { + 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(); \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index 9b5cc7c30..e4ac17f75 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -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, }, diff --git a/src/store/intelligenceSlice.ts b/src/store/intelligenceSlice.ts new file mode 100644 index 000000000..7fd54ef22 --- /dev/null +++ b/src/store/intelligenceSlice.ts @@ -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; + currentChatSession: string | null; + + // Execution Management + activeExecutions: Record; + + // 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) => { + state.initialized = action.payload; + }, + + setConnectionStatus: ( + state, + action: PayloadAction<'disconnected' | 'connecting' | 'connected' | 'error'> + ) => { + state.connectionStatus = action.payload; + }, + + // Items actions + setItems: (state, action: PayloadAction) => { + state.items = action.payload; + state.lastUpdate = new Date(); + }, + + addItem: (state, action: PayloadAction) => { + state.items.unshift(action.payload); + state.lastUpdate = new Date(); + }, + + removeItem: (state, action: PayloadAction) => { + state.items = state.items.filter(item => item.id !== action.payload); + state.lastUpdate = new Date(); + }, + + // Filters actions + setSourceFilter: (state, action: PayloadAction) => { + state.filters.source = action.payload; + }, + + setPriorityFilter: (state, action: PayloadAction<'critical' | 'important' | 'normal' | 'all'>) => { + state.filters.priority = action.payload; + }, + + setSearchFilter: (state, action: PayloadAction) => { + 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) => { + const threadId = action.payload; + delete state.activeSessions[threadId]; + if (state.currentChatSession === threadId) { + state.currentChatSession = null; + } + }, + + setCurrentChatSession: (state, action: PayloadAction) => { + 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) => { + 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; \ No newline at end of file diff --git a/src/store/threadSlice.ts b/src/store/threadSlice.ts index fefd58f08..14aa0767c 100644 --- a/src/store/threadSlice.ts +++ b/src/store/threadSlice.ts @@ -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 diff --git a/src/types/intelligence-chat-api.ts b/src/types/intelligence-chat-api.ts new file mode 100644 index 000000000..1efeff955 --- /dev/null +++ b/src/types/intelligence-chat-api.ts @@ -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 }; +} + +export interface CreateChatSessionResponse { + success: true; + data: { + session_id: string; + initial_message: { + content: string; + type: MessageType; + options: string[]; + context: Record; + }; + expected_flow: ConversationFlow[]; + }; +} + +export interface ChatMessage { + id: string; + content: string; + sender: 'ai' | 'user'; + timestamp: string; // ISO 8601 + type: MessageType; + metadata?: Record; +} + +export interface ChatSessionDetails { + session_id: string; + status: SessionStatus; + current_flow: ConversationFlow; + messages: ChatMessage[]; + context: Record; + execution_id?: string; +} + +export interface SendMessageRequest { + content: string; + sender: 'user'; + current_flow: ConversationFlow; + context: Record; +} + +export interface SendMessageResponse { + success: true; + data: { + ai_response: { + content: string; + type: MessageType; + next_flow: ConversationFlow; + options: string[]; + metadata: Record; + }; + 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; +} + +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; +} + +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; +} + +// ===== WebSocket Event Types ===== + +export interface WebSocketMessage { + 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; 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; + 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; +} + +// ===== Standard API Response Envelope ===== + +export interface APIResponse { + 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; +} + +// ===== 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(obj: any): obj is WebSocketMessage { + return typeof obj === 'object' && obj !== null && typeof obj.type === 'string' && 'data' in obj; +} + +export function isAPIResponse(obj: any): obj is APIResponse { + 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 = (event: WebSocketMessage) => 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; + startExecution: (planId: string) => Promise; + connect: () => Promise; + disconnect: () => void; + clearError: () => void; +} diff --git a/src/types/intelligence.ts b/src/types/intelligence.ts new file mode 100644 index 000000000..6e56bf829 --- /dev/null +++ b/src/types/intelligence.ts @@ -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; +} + +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; +} diff --git a/src/utils/intelligenceTransforms.ts b/src/utils/intelligenceTransforms.ts new file mode 100644 index 000000000..bc366b25c --- /dev/null +++ b/src/utils/intelligenceTransforms.ts @@ -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 { + 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', + }; + } +} \ No newline at end of file