Merge remote-tracking branch 'upstream/develop' into develop

This commit is contained in:
M3gA-Mind
2026-03-26 22:27:01 +05:30
29 changed files with 4069 additions and 2119 deletions
-459
View File
@@ -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
-350
View File
@@ -1,350 +0,0 @@
# Daemon Lifecycle Management System
**Status**: ✅ Implemented
**Version**: 1.0.0
**Date**: February 2026
## Overview
The Daemon Lifecycle Management System provides comprehensive frontend integration with the alphahuman Rust daemon, enabling real-time health monitoring, automatic lifecycle management, and user-friendly daemon controls throughout the application.
## Background
### Problem Statement
The alphahuman application runs a sophisticated Rust daemon that manages critical backend services (gateway, channels, heartbeat, scheduler) and emits detailed health information every 5 seconds. However, the frontend previously had:
- **Poor Visibility**: Daemon controls buried in developer console only
- **No Real-Time Monitoring**: Manual status checks with raw JSON output
- **No Automatic Management**: No startup detection or error recovery
- **Disconnected UX**: Health events from Rust were ignored by frontend
- **Manual Recovery**: Users had to manually restart failed services
### Solution Overview
This implementation creates a complete bridge between the Rust daemon's health system and the React frontend, providing:
- Real-time health monitoring with visual indicators
- Automatic daemon startup and error recovery
- User-friendly health displays in main UI
- Enhanced developer tools with live status
- Coordinated daemon and socket connection management
## Architecture
### System Components
```
┌─────────────────────────────────────────────────────────────┐
│ React Frontend │
├─────────────────────────────────────────────────────────────┤
│ UI Layer │
│ ├── DaemonHealthIndicator (Main UI) │
│ ├── DaemonHealthPanel (Detailed View) │
│ └── Enhanced TauriCommandsPanel (Dev Tools) │
├─────────────────────────────────────────────────────────────┤
│ State Management │
│ ├── daemonSlice.ts (Redux) │
│ ├── useDaemonHealth.ts (React Hook) │
│ └── useDaemonLifecycle.ts (Lifecycle Hook) │
├─────────────────────────────────────────────────────────────┤
│ Services │
│ └── daemonHealthService.ts (Event Processing) │
├─────────────────────────────────────────────────────────────┤
│ Integration Layer │
│ ├── tauriSocket.ts (Event Listening) │
│ └── SocketProvider.tsx (Coordinated State) │
└─────────────────────────────────────────────────────────────┘
Tauri Event Bridge
┌─────────────────────────────────────────────────────────────┐
│ Rust Daemon │
├─────────────────────────────────────────────────────────────┤
│ Health System (src-tauri/src/alphahuman/health/mod.rs) │
│ ├── Component Health Tracking │
│ ├── Health Snapshot Generation │
│ └── Event Emission (every 5s) │
├─────────────────────────────────────────────────────────────┤
│ Daemon Supervisor (src-tauri/src/alphahuman/daemon/mod.rs) │
│ ├── Component Management │
│ ├── Automatic Restarts │
│ └── Exponential Backoff │
├─────────────────────────────────────────────────────────────┤
│ Components │
│ ├── Gateway (API Server) │
│ ├── Channels (Communication) │
│ ├── Heartbeat (Health Monitor) │
│ └── Scheduler (Cron Jobs) │
└─────────────────────────────────────────────────────────────┘
```
### Event Flow
1. **Health Generation**: Rust daemon generates health snapshots every 5 seconds
2. **Event Emission**: Health data emitted via `alphahuman:health` Tauri event
3. **Frontend Processing**: `daemonHealthService` receives and parses events
4. **State Updates**: Redux state updated with component health information
5. **UI Reactivity**: React components automatically re-render with new status
6. **User Actions**: Manual controls trigger Tauri commands back to Rust
## Implementation Details
### Redux State Management
**File**: `src/store/daemonSlice.ts`
```typescript
interface DaemonUserState {
status: 'starting' | 'running' | 'error' | 'disconnected';
healthSnapshot: HealthSnapshot | null;
components: {
gateway?: ComponentHealth;
channels?: ComponentHealth;
heartbeat?: ComponentHealth;
scheduler?: ComponentHealth;
};
lastHealthUpdate: string | null;
connectionAttempts: number;
autoStartEnabled: boolean;
isRecovering: boolean;
healthTimeoutId: string | null;
}
```
**Key Features**:
- Per-user daemon state isolation (following existing patterns)
- Component-level health tracking with error details
- Connection attempt management with exponential backoff
- Auto-start preference persistence
- Timeout handling for health event detection
### Health Monitoring Service
**File**: `src/services/daemonHealthService.ts`
```typescript
export class DaemonHealthService {
private healthTimeoutId: NodeJS.Timeout | null = null;
private readonly HEALTH_TIMEOUT_MS = 30000; // 30 seconds
async setupHealthListener(): Promise<UnlistenFn | null>;
private parseHealthSnapshot(payload: unknown): HealthSnapshot | null;
private updateReduxFromHealth(snapshot: HealthSnapshot): void;
startHealthTimeout(): void;
}
```
**Responsibilities**:
- Listen for `alphahuman:health` Tauri events from Rust
- Parse and validate health snapshot data
- Update Redux state with component health information
- Manage 30-second timeout detection for disconnected daemon
- Handle cleanup and error recovery
### UI Components
#### DaemonHealthIndicator
**File**: `src/components/daemon/DaemonHealthIndicator.tsx`
**Purpose**: Compact status indicator for main application UI
**Visual States**:
- 🟢 **Green**: All components running healthy (`status: 'running'`)
- 🟡 **Yellow**: Daemon starting or recovering (`status: 'starting'`)
- 🔴 **Red**: One or more components in error state (`status: 'error'`)
-**Gray**: Daemon disconnected or not running (`status: 'disconnected'`)
**Features**:
- Click to open detailed health panel
- Tooltip showing component health summary
- Responsive sizing (sm/md/lg variants)
- Only visible in Tauri environments
#### DaemonHealthPanel
**File**: `src/components/daemon/DaemonHealthPanel.tsx`
**Purpose**: Detailed health breakdown and manual controls
**Features**:
- Component health table with status, last update, restart counts
- Manual restart buttons for individual components
- Auto-start toggle with persistence
- Connection retry controls
- Real-time health information display
- User-friendly error messages and troubleshooting hints
### Lifecycle Management
**File**: `src/hooks/useDaemonLifecycle.ts`
**Automatic Behaviors**:
1. **App Startup**:
- Detect existing daemon status
- Auto-start daemon if enabled and not running
- Setup health monitoring listeners
2. **Error Recovery**:
- Exponential backoff retry attempts (1s → 2s → 4s → 8s → 30s max)
- Maximum 5 retry attempts before requiring manual intervention
- Clear error states on successful recovery
3. **Background Handling**:
- Maintain health monitoring when app backgrounded
- Reconnection logic without page refresh
- Coordinate with socket connection management
4. **Cleanup**:
- Proper event listener cleanup on unmount
- Timeout cancellation to prevent memory leaks
- Graceful shutdown coordination
### Integration Points
#### Enhanced Service Management
**File**: `src/components/settings/panels/TauriCommandsPanel.tsx`
**Improvements to Existing Developer Console**:
- **Live Status Display**: Real-time daemon status with PID and uptime
- **Component Health Grid**: Visual status for all daemon components
- **Enhanced Controls**: Smart start/stop buttons with proper loading states
- **Auto-Start Toggle**: User preference for automatic daemon startup
- **Connection Tracking**: Display retry attempts and recovery status
- **Better Error Messages**: User-friendly errors with troubleshooting hints
**Before vs After**:
```typescript
// Before: Manual status check
<PrimaryButton onClick={() => run(alphahumanServiceStatus, 'serviceStatus')}>
Status
</PrimaryButton>
// After: Live status display
<div className="flex items-center gap-3">
<DaemonHealthIndicator size="md" />
<div>
<div className="text-white font-medium">Daemon Status: {status}</div>
<div className="text-xs text-gray-400">PID: {pid} | Uptime: {uptime}</div>
</div>
</div>
```
#### Socket Provider Integration
**File**: `src/providers/SocketProvider.tsx`
**Coordinated State Management**:
- Check daemon health before attempting socket connections
- Display daemon-related errors in socket connection status
- Coordinate daemon startup with socket connection flows
- Provide daemon health context to socket consumers
#### Main UI Integration
**File**: `src/components/MiniSidebar.tsx`
**User-Facing Integration**:
- Daemon health indicator in main navigation (Tauri-only)
- Click to open detailed health modal
- Non-intrusive but easily accessible
- Consistent with existing UI patterns
## User Experience
### For End Users
1. **Visible Status**: Daemon health indicator in main UI shows system status at a glance
2. **Automatic Operation**: Daemon starts automatically and recovers from errors without user intervention
3. **Clear Feedback**: User-friendly messages explain daemon state and provide actionable guidance
4. **Quick Access**: Click health indicator to see detailed component status and manual controls
### For Developers
1. **Enhanced Console**: Improved service management in settings with live status updates
2. **Component Monitoring**: Real-time visibility into all daemon components (gateway, channels, etc.)
3. **Debug Information**: Detailed health snapshots, retry attempts, and error history
4. **Manual Override**: Full control over daemon lifecycle with proper state management
### Error Handling
1. **Graceful Degradation**: System works properly in non-Tauri environments
2. **Timeout Management**: 30-second timeout detection with automatic recovery attempts
3. **User Guidance**: Clear error messages with troubleshooting suggestions
4. **Recovery Actions**: Manual restart options when automatic recovery fails
## Technical Benefits
### Performance
- **Efficient Updates**: Debounced Redux updates prevent excessive re-renders
- **Memory Management**: Proper cleanup of timeouts and event listeners
- **Background Optimization**: Minimal resource usage when app backgrounded
### Reliability
- **Timeout Handling**: Robust detection of daemon disconnection
- **Exponential Backoff**: Smart retry logic prevents resource exhaustion
- **State Consistency**: Coordinated daemon and socket connection states
- **Error Recovery**: Automatic recovery from transient failures
### Maintainability
- **TypeScript**: Full type safety throughout the implementation
- **Modular Design**: Clear separation between state, services, and UI components
- **Existing Patterns**: Follows established Redux and component patterns
- **Testing**: Comprehensive error handling and edge case management
## Configuration
### Auto-Start Behavior
Users can control daemon auto-start behavior through:
1. **UI Toggle**: Available in both health panel and settings console
2. **Persistence**: Preference stored in Redux with persistence
3. **Default**: Auto-start enabled by default for better UX
### Health Monitoring
- **Event Frequency**: Rust daemon emits health every 5 seconds
- **Timeout Duration**: 30 seconds without health events = disconnected
- **Retry Logic**: Maximum 5 attempts with exponential backoff
- **Component Tracking**: Gateway, channels, heartbeat, scheduler components
## Future Enhancements
### Potential Improvements
1. **Health History**: Track daemon health over time with charts/graphs
2. **Performance Metrics**: CPU/memory usage from daemon components
3. **Log Integration**: Show daemon logs directly in health panel
4. **Mobile Optimization**: Enhanced mobile-specific daemon management
5. **Notification System**: Push notifications for critical daemon events
### Extensibility
The architecture supports easy extension for:
- Additional daemon components
- Custom health check logic
- Third-party integrations
- Advanced monitoring features
## Conclusion
The Daemon Lifecycle Management System provides a complete bridge between the sophisticated Rust daemon infrastructure and the React frontend, delivering excellent user experience while maintaining the technical robustness required for a production application.
The implementation follows established patterns, provides comprehensive error handling, and creates a foundation for future daemon-related features while ensuring the system remains reliable and user-friendly.
-627
View File
@@ -1,627 +0,0 @@
# Gmail Skill Integration — Design Reference
**Scope:** Native Gmail skill — OAuth2 connect flow, tunnel-based callback, token storage,
initial inbox sync, and `send_email` tool dispatch.
**Status:** Design reference / implementation guide.
---
## 1. Overview
This document describes the full lifecycle of connecting a Gmail skill to ZeroClaw.
It covers four concerns in order:
1. **OAuth2 connect** — how the user authorises Gmail access
2. **Tunnel callback** — how the authorization code reaches the backend through the tunnel
3. **Token storage** — how credentials are encrypted and persisted in the auth profile store
4. **Email sync + send** — how the initial inbox is fetched and how sending works
The skill is declared with the following manifest:
```json
{
"id": "gmail",
"name": "Gmail",
"version": "1.0.0",
"description": "Gmail integration via Google API — comprehensive email management with OAuth2 authentication, send/receive, labels, search, and attachments.",
"auto_start": false,
"platforms": ["windows", "macos", "linux", "android", "ios"],
"setup": {
"required": true,
"label": "Connect Gmail",
"oauth": {
"provider": "gmail",
"scopes": [
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.labels"
]
}
}
}
```
The equivalent `SKILL.toml` that ZeroClaw loads from
`~/.zeroclaw/workspace/skills/gmail/SKILL.toml` is shown in §2.
---
## 2. Skill Manifest (`SKILL.toml`)
Skills in ZeroClaw are loaded by `src/skills/mod.rs`. The loader reads a `SKILL.toml`
(or `SKILL.md`) file from `~/.zeroclaw/workspace/skills/<name>/`. The Rust struct it
populates is `Skill` / `SkillManifest` / `SkillTool`.
`SKILL.toml` for Gmail:
```toml
[skill]
name = "gmail"
version = "1.0.0"
description = "Gmail integration via Google API — send/receive, labels, search, attachments."
author = "zeroclaw"
tags = ["email", "google", "productivity"]
# OAuth setup block — interpreted by the skill connect subsystem (see §3).
[skill.setup]
required = true
label = "Connect Gmail"
[skill.setup.oauth]
provider = "gmail"
scopes = [
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.labels",
]
# Tools exposed to the agent after connection.
[[tools]]
name = "gmail_send_email"
description = "Send an email via Gmail. Requires a connected Gmail account."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
[[tools]]
name = "gmail_list_messages"
description = "List messages in the Gmail inbox. Supports query filters."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages"
[[tools]]
name = "gmail_get_message"
description = "Fetch the full content of a single Gmail message by ID."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}"
[[tools]]
name = "gmail_modify_labels"
description = "Add or remove labels on a Gmail message."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}/modify"
```
At runtime, `load_skills_with_config()` (`src/skills/mod.rs:78`) reads these tools into
`Vec<SkillTool>` and injects them into the agent system prompt. The `kind = "http"` tools
are dispatched via `HttpRequestTool` (`src/tools/http_request.rs`) with the stored
Bearer token injected as the `Authorization` header (see §5).
---
## 3. OAuth2 Connect Flow
### 3.1 What the user triggers
When the user asks to connect Gmail (via the frontend or a CLI command), the backend:
1. Generates a **PKCE pair** (code verifier + SHA-256 challenge) — following the same
pattern as `src/auth/openai_oauth.rs:generate_pkce_state()`.
2. Generates a random CSRF `state` token (24 bytes, base64url).
3. Constructs the Google OAuth2 authorisation URL:
```
https://accounts.google.com/o/oauth2/v2/auth
?response_type=code
&client_id=<GOOGLE_CLIENT_ID>
&redirect_uri=<TUNNEL_PUBLIC_URL>/auth/callback/gmail
&scope=https://www.googleapis.com/auth/gmail.send
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/gmail.labels
&code_challenge=<SHA256_CHALLENGE>
&code_challenge_method=S256
&state=<RANDOM_STATE>
&access_type=offline
&prompt=consent
```
`access_type=offline` is required to receive a `refresh_token` from Google.
`prompt=consent` ensures the refresh token is issued on every connect, not just the first.
4. The URL is sent to the frontend. The frontend opens it in a browser or WebView.
### 3.2 Where the redirect URI points
The `redirect_uri` is the **tunnel public URL** with the path `/auth/callback/gmail`.
ZeroClaw's tunnel system (`src/tunnel/mod.rs`) exposes the local gateway port via one of:
| Provider | How public URL is obtained |
| ------------ | ---------------------------------------------------------------- |
| `cloudflare` | `cloudflared tunnel` process stdout — `src/tunnel/cloudflare.rs` |
| `ngrok` | ngrok local API `GET /api/tunnels``src/tunnel/ngrok.rs` |
| `tailscale` | `tailscale funnel` + hostname — `src/tunnel/tailscale.rs` |
| `custom` | user-supplied URL or stdout pattern — `src/tunnel/custom.rs` |
The tunnel's `start(local_host, local_port)` method returns the public URL string.
This URL is what gets used as the `redirect_uri`.
Example with Cloudflare Tunnel:
```
https://your-subdomain.trycloudflare.com/auth/callback/gmail
```
Google's OAuth2 server sends the browser to this URL after the user grants consent.
---
## 4. Receiving the Authorization Code via the Tunnel
### 4.1 The callback endpoint
The gateway (`src/gateway/mod.rs`) runs as an Axum HTTP server. A new route needs to be
registered to receive the OAuth2 callback:
```
GET /auth/callback/gmail?code=<AUTH_CODE>&state=<STATE>
```
This route handler must:
1. **Validate the `state` parameter** against the CSRF token stored in memory when the
flow was initiated. Reject mismatches with `400 Bad Request`.
2. **Extract the `code`** query parameter.
3. **Exchange the code** for tokens by calling Google's token endpoint:
```
POST https://oauth2.googleapis.com/token
grant_type=authorization_code
code=<AUTH_CODE>
client_id=<GOOGLE_CLIENT_ID>
client_secret=<GOOGLE_CLIENT_SECRET>
redirect_uri=<TUNNEL_PUBLIC_URL>/auth/callback/gmail
code_verifier=<PKCE_VERIFIER>
```
The exchange pattern mirrors `src/auth/openai_oauth.rs:exchange_code_for_tokens()`,
which posts a form body and parses the JSON response into `TokenSet`.
Google returns:
```json
{
"access_token": "ya29.a0...",
"expires_in": 3599,
"refresh_token": "1//0g...",
"scope": "https://www.googleapis.com/auth/gmail.send ...",
"token_type": "Bearer"
}
```
4. The handler stores the `TokenSet` (see §4.2) and returns a success response to the
browser (an HTML page or redirect to the frontend app confirming the connection).
### 4.2 The in-flight CSRF / PKCE state
Before redirecting the user to Google, the pending PKCE state and CSRF token must be
stored so the callback handler can look them up. The right storage is the skill's dedicated
in-memory or short-lived file-backed state, keyed by the `state` value:
```
pending_oauth_states: HashMap<state_token, (PkceState, initiated_at)>
```
Entries expire after a fixed window (e.g. 10 minutes) to prevent orphaned state
accumulation. This follows the same pattern ZeroClaw already uses for pairing nonces
in `src/security/pairing.rs`.
---
## 5. Token Storage
After a successful token exchange, the tokens are persisted using the `AuthService`
and `AuthProfilesStore` from `src/auth/`.
### 5.1 Storing the token set
```rust
// Pseudocode — mirrors src/auth/mod.rs AuthService::store_openai_tokens()
let token_set = TokenSet {
access_token: response.access_token,
refresh_token: response.refresh_token,
id_token: None,
expires_at: Some(Utc::now() + Duration::seconds(response.expires_in)),
token_type: Some("Bearer".into()),
scope: Some(scopes.join(" ")),
};
auth_service
.store_oauth_tokens("gmail", "default", token_set, None, true)
.await?;
```
`store_oauth_tokens` calls `AuthProfilesStore::upsert_profile()`, which:
1. Creates or updates an `AuthProfile` with `kind = AuthProfileKind::OAuth`.
2. Serialises the profile data to `~/.zeroclaw/auth-profiles.json` (file-locked via
`auth-profiles.lock` with a 10-second timeout — `src/auth/profiles.rs:1617`).
### 5.2 How tokens are encrypted at rest
Before writing to disk, every secret field passes through `SecretStore::encrypt()`
(`src/security/secrets.rs`). The encryption scheme is:
- **Algorithm**: ChaCha20-Poly1305 AEAD (256-bit key)
- **Key file**: `~/.zeroclaw/.secret_key` (permissions 0600, created on first use)
- **Format on disk**: `enc2:<hex(12-byte-nonce ‖ ciphertext ‖ 16-byte-Poly1305-tag)>`
- **Config**: encryption is enabled by default; disable with `secrets.encrypt = false`
The stored profile entry looks like:
```json
{
"id": "gmail:default",
"provider": "gmail",
"profile_name": "default",
"kind": "oauth",
"token_set": {
"access_token": "enc2:a1b2c3...",
"refresh_token": "enc2:d4e5f6...",
"expires_at": "2026-03-09T12:00:00Z",
"token_type": "Bearer",
"scope": "https://www.googleapis.com/auth/gmail.send ..."
},
"created_at": "2026-03-09T11:00:00Z",
"updated_at": "2026-03-09T11:00:00Z"
}
```
### 5.3 Token refresh
Google access tokens expire after 3600 seconds. Before any Gmail API call, the skill
must check `token_set.is_expiring_within(Duration::from_secs(90))` (the same 90-second
skew used for OpenAI tokens in `src/auth/mod.rs:160`). If expiring:
```
POST https://oauth2.googleapis.com/token
grant_type=refresh_token
refresh_token=<stored_refresh_token>
client_id=<GOOGLE_CLIENT_ID>
client_secret=<GOOGLE_CLIENT_SECRET>
```
The new `access_token` (and new `refresh_token` if Google rotates it) is written back
to the auth profile via `AuthProfilesStore::update_profile()`.
---
## 6. Initial Email Sync (First 100 Emails)
Once connected, the skill performs a one-time initial sync. This is triggered
immediately after a successful token exchange and runs as a background task.
### 6.1 Fetch message IDs
```
GET https://gmail.googleapis.com/gmail/v1/users/me/messages
?maxResults=100
&labelIds=INBOX
Authorization: Bearer <access_token>
```
Returns up to 100 message descriptors: `[{ "id": "...", "threadId": "..." }, ...]`.
### 6.2 Fetch full message details
For each message ID, fetch the full message:
```
GET https://gmail.googleapis.com/gmail/v1/users/me/messages/{id}
?format=full
Authorization: Bearer <access_token>
```
The response contains headers (From, To, Subject, Date), a snippet, and the body parts.
In practice, to avoid 100 sequential requests, use the Gmail batch API:
```
POST https://www.googleapis.com/batch/gmail/v1
Content-Type: multipart/mixed; boundary="batch_boundary"
--batch_boundary
Content-Type: application/http
GET /gmail/v1/users/me/messages/{id1}?format=full
...
```
### 6.3 Storing emails in ZeroClaw memory
Each email is stored via `memory_store` (`src/tools/memory_store.rs`) with:
| Field | Value |
| ---------- | ----------------------------------------------------------------- |
| `key` | `gmail_msg_<message_id>` |
| `content` | Formatted string: `From: ... Subject: ... Date: ... Snippet: ...` |
| `category` | `MemoryCategory::Custom("gmail")` |
This makes every synced email searchable via `memory_recall` with queries like
`"gmail from:zeroclaw@example.com"`.
For larger body content that exceeds a single memory entry, store the full body as a
separate entry keyed `gmail_body_<message_id>` and the summary/metadata as
`gmail_msg_<message_id>`.
Example stored entries after sync:
```
key: gmail_msg_18de7f2a1b3c4e5d
content: From: sender@example.com
To: me@gmail.com
Subject: Project update
Date: 2026-03-08T14:32:00Z
Snippet: "Here is the latest update on the project..."
Labels: INBOX, UNREAD
category: gmail
key: gmail_body_18de7f2a1b3c4e5d
content: (full decoded email body text)
category: gmail
```
### 6.4 Sync state tracking
Store the highest-known history ID after sync so incremental sync can pick up from
where it left off:
```
key: gmail_sync_history_id
content: 12345678
category: core
```
---
## 7. The `gmail_send_email` Tool
### 7.1 Tool definition in `SKILL.toml`
The `[[tools]]` entry from §2:
```toml
[[tools]]
name = "gmail_send_email"
description = "Send an email via Gmail. Requires a connected Gmail account."
kind = "http"
command = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send"
```
### 7.2 How the agent calls it
The LLM emits a tool call:
```json
{
"name": "gmail_send_email",
"arguments": {
"to": "recipient@example.com",
"subject": "Hello from ZeroClaw",
"body": "This is a test email sent by the agent."
}
}
```
### 7.3 Tool dispatch path
```
Agent loop (src/agent/loop_.rs)
└── dispatches to registered tool by name
└── HttpRequestTool::execute(args) [src/tools/http_request.rs]
├── Retrieve gmail access_token from AuthService
│ └── auth_service.get_provider_bearer_token("gmail", None)
│ └── decrypts enc2: value via SecretStore::decrypt()
├── Build RFC 2822 message and base64url-encode it
├── POST https://gmail.googleapis.com/gmail/v1/users/me/messages/send
│ Headers:
│ Authorization: Bearer <decrypted_access_token>
│ Content-Type: application/json
│ Body:
│ { "raw": "<base64url_encoded_rfc2822_message>" }
└── Return ToolResult { success, output: "Message sent. ID: <id>" }
```
### 7.4 Building the RFC 2822 message
Gmail's send endpoint requires the email encoded as an RFC 2822 message, then base64url
encoded. The structure:
```
From: me@gmail.com
To: recipient@example.com
Subject: Hello from ZeroClaw
Content-Type: text/plain; charset="UTF-8"
MIME-Version: 1.0
This is a test email sent by the agent.
```
Then the entire string is base64url-encoded (no padding) and placed in the `raw` field:
```json
{ "raw": "RnJvbTogbWVAZ21haWwuY29tCi..." }
```
### 7.5 Security enforcement
Before executing any outbound HTTP call, the `SecurityPolicy` is consulted:
```rust
// src/security/policy.rs
security.enforce_tool_operation(ToolOperation::Act, "gmail_send_email")
```
`ToolOperation::Act` is the category for write/side-effect operations. If the agent is
running in `ReadOnly` or `Supervised` mode, this call fails with an explicit error before
any network request is made.
The `HttpRequestTool` additionally enforces the `http.allowed_domains` allowlist. For
Gmail tools, `googleapis.com` must be present in that list:
```toml
# config.toml
[http]
enabled = true
allowed_domains = ["googleapis.com"]
```
### 7.6 Reply-to and threading
To send a reply within an existing thread, add the Gmail `threadId` to the request body:
```json
{ "raw": "<base64url_message_with_In-Reply-To_header>", "threadId": "18de7f2a1b3c4e5d" }
```
The RFC 2822 message must include `In-Reply-To: <original_message_id>` and
`References: <original_message_id>` headers for proper threading.
---
## 8. Full End-to-End Sequence
```
Frontend / User
│ 1. "Connect Gmail"
ZeroClaw Backend
│ 2. Generate PKCE + CSRF state
│ 3. Build Google OAuth2 authorize URL with tunnel redirect_uri
│ 4. Return URL to frontend
Browser (user)
│ 5. User visits URL, grants consent in Google
Google OAuth2
│ 6. Browser redirected to <TUNNEL_PUBLIC_URL>/auth/callback/gmail?code=XXX&state=YYY
Tunnel (cloudflare / ngrok / tailscale)
│ 7. Proxies HTTPS request to local gateway port
Gateway (src/gateway/mod.rs) — GET /auth/callback/gmail
│ 8. Validate CSRF state
│ 9. Exchange code → TokenSet via POST https://oauth2.googleapis.com/token
│ 10. Store TokenSet in AuthProfilesStore (encrypted, "gmail:default")
│ 11. Return success page/redirect to frontend
Background task — initial sync
│ 12. GET /gmail/v1/users/me/messages?maxResults=100&labelIds=INBOX
│ 13. Batch-fetch 100 full messages
│ 14. memory_store each email under key=gmail_msg_<id>, category=gmail
│ 15. memory_store gmail_sync_history_id = <latest_historyId>
Agent (subsequent interactions)
│ 16. LLM generates tool call: gmail_send_email { to, subject, body }
│ 17. Agent loop dispatches to HttpRequestTool
│ 18. HttpRequestTool retrieves + decrypts access_token from AuthService
│ 19. Refreshes token if expiring within 90 seconds
│ 20. POST https://gmail.googleapis.com/gmail/v1/users/me/messages/send
│ 21. Returns ToolResult to agent
Done
```
---
## 9. Configuration Reference
All Gmail skill config lives in `config.toml`:
```toml
# Enable HTTP request tool with Google API access
[http]
enabled = true
allowed_domains = ["googleapis.com", "oauth2.googleapis.com"]
timeout_secs = 30
max_response_size = 524288 # 512KB — enough for email batch responses
# Tunnel for OAuth callback (pick one)
[tunnel]
provider = "cloudflare"
[tunnel.cloudflare]
token = "your-cloudflare-tunnel-token"
# Or ngrok:
# [tunnel]
# provider = "ngrok"
# [tunnel.ngrok]
# auth_token = "your-ngrok-token"
# Google OAuth2 app credentials (stored encrypted)
# Set via environment or onboard wizard — never commit raw values
[integrations.gmail]
client_id = "enc2:..."
client_secret = "enc2:..."
```
The Google credentials (`client_id`, `client_secret`) are encrypted by `SecretStore`
before being written to `config.toml` — the same ChaCha20-Poly1305 scheme used for all
secrets (`src/security/secrets.rs`).
---
## 10. Key Source Files
| File | Role |
| ---------------------------- | ----------------------------------------------------------------- |
| `src/skills/mod.rs` | Skill loading, `SkillTool` struct, `load_skills_with_config()` |
| `src/auth/mod.rs` | `AuthService` — store/retrieve/refresh OAuth tokens |
| `src/auth/profiles.rs` | `AuthProfile`, `TokenSet`, `AuthProfilesStore` — JSON persistence |
| `src/auth/openai_oauth.rs` | PKCE generation, code exchange, refresh — reference pattern |
| `src/security/secrets.rs` | `SecretStore` — ChaCha20-Poly1305 encrypt/decrypt |
| `src/tunnel/mod.rs` | `Tunnel` trait + factory — public URL for OAuth redirect |
| `src/tunnel/cloudflare.rs` | Cloudflare Tunnel implementation |
| `src/tunnel/ngrok.rs` | ngrok implementation |
| `src/gateway/mod.rs` | Axum HTTP gateway — where `/auth/callback/gmail` is registered |
| `src/tools/http_request.rs` | `HttpRequestTool` — dispatches Gmail API calls |
| `src/tools/memory_store.rs` | `MemoryStoreTool` — stores synced emails |
| `src/tools/memory_recall.rs` | `MemoryRecallTool` — searches synced emails |
| `src/security/policy.rs` | `SecurityPolicy` — enforces `ToolOperation::Act` guards |
| `docs/config-reference.md` | Full config schema including `[http]`, `[tunnel]` |
---
## 11. Security Notes
- The OAuth2 `redirect_uri` **must be the tunnel URL**. It cannot be `localhost` in a
remote/mobile scenario. Google validates the exact URI registered in the Google Cloud
Console; register it as `https://<your-tunnel-domain>/auth/callback/gmail`.
- The CSRF `state` token must be validated on every callback. Reject mismatched or
missing state with `400 Bad Request` before touching any tokens.
- The PKCE verifier must be destroyed after a successful or failed exchange — never
persist it beyond the in-flight flow.
- `client_secret` must never appear in plaintext in `config.toml`, logs, or agent
tool output. Encrypt it via the secret store and redact it in observability output.
- `googleapis.com` must be explicitly present in `http.allowed_domains` — the
`HttpRequestTool` enforces this allowlist before every request
(`src/tools/http_request.rs`).
- Token refresh runs under the same per-profile Tokio mutex used for OpenAI tokens
(`src/auth/mod.rs:287`) to prevent double-refresh races.
-530
View File
@@ -1,530 +0,0 @@
# AlphaHuman Teams API Reference
Complete reference for all teams-related API endpoints in the AlphaHuman platform.
**Base URL**: `https://api.alphahuman.xyz`
**Authentication**: Bearer JWT token required for all endpoints
**Content-Type**: `application/json`
---
## Core Team Management
### 1. Create Team
**POST** `/teams`
Creates a new team with optional encryption.
**Request Body:**
```json
{ "name": "string (required)", "magicWord": "string (optional)" }
```
**Response Schema:**
```json
{
"id": "string",
"name": "string",
"slug": "string",
"magicWord": "string | null",
"createdBy": "string",
"isPersonal": "boolean",
"subscription": {
"hasActiveSubscription": "boolean",
"plan": "FREE|BASIC|PRO",
"planExpiry": "date-time | null",
"stripeCustomerId": "string | null"
},
"usage": {
"weeklyBudgetUsd": "number",
"spentThisWeekUsd": "number",
"weekStartDate": "date-time"
},
"inviteCode": "string | null",
"maxMembers": "number",
"createdAt": "date-time",
"updatedAt": "date-time"
}
```
**Status Codes:**
- `200` - Team created successfully
- `401` - Unauthorized
---
### 2. List Teams
**GET** `/teams`
Retrieves all teams the authenticated user belongs to.
**Response Schema:**
```json
{
"success": true,
"data": [
{
"team": {
"id": "string",
"name": "string",
"slug": "string",
"isPersonal": "boolean",
"subscription": { "hasActiveSubscription": "boolean", "plan": "FREE|BASIC|PRO" }
},
"role": "admin|billing_manager|member"
}
]
}
```
**Status Codes:**
- `200` - Success
- `401` - Unauthorized
---
### 3. Get Team Details
**GET** `/teams/{teamId}`
Retrieves detailed information for a specific team.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Response Schema:**
```json
{
"id": "string",
"name": "string",
"slug": "string",
"magicWord": "string | null",
"createdBy": "string",
"isPersonal": "boolean",
"subscription": {
"hasActiveSubscription": "boolean",
"plan": "FREE|BASIC|PRO",
"planExpiry": "date-time | null",
"stripeCustomerId": "string | null"
},
"usage": {
"weeklyBudgetUsd": "number",
"spentThisWeekUsd": "number",
"weekStartDate": "date-time"
},
"inviteCode": "string | null",
"maxMembers": "number",
"createdAt": "date-time",
"updatedAt": "date-time"
}
```
**Status Codes:**
- `200` - Success
- `403` - Not a member of the team
- `404` - Team not found
---
### 4. Update Team Settings
**PUT** `/teams/{teamId}`
Updates team settings. **Admin only**.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Request Body:**
```json
{ "name": "string (optional)", "maxMembers": "number (optional)" }
```
**Status Codes:**
- `200` - Team updated successfully
- `403` - Only admins can update team settings
---
### 5. Delete Team
**DELETE** `/teams/{teamId}`
Deletes a team. **Admin only, non-personal teams only**.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Status Codes:**
- `200` - Team deleted successfully
- `400` - Cannot delete a personal team
- `403` - Only admins can delete a team
---
### 6. Switch Active Team
**POST** `/teams/{teamId}/switch`
Changes the user's active team.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Status Codes:**
- `200` - Successfully switched active team
- `403` - Not a member of the specified team
---
## Team Member Management
### 7. List Team Members
**GET** `/teams/{teamId}/members`
Lists all members of a team.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Response Schema:**
```json
{
"success": true,
"data": [{ "user": "string", "role": "admin|billing_manager|member", "joinedAt": "date-time" }]
}
```
**Status Codes:**
- `200` - Success
- `403` - Not a member of this team
---
### 8. Remove Team Member
**DELETE** `/teams/{teamId}/members/{userId}`
Removes a member from the team. **Admin only**.
**Parameters:**
- `teamId` (path, required) - Team identifier
- `userId` (path, required) - User identifier to remove
**Status Codes:**
- `200` - Member removed
- `403` - Only admins can remove members
---
### 9. Change Member Role
**PUT** `/teams/{teamId}/members/{userId}/role`
Changes a member's role within the team. **Admin only**.
**Parameters:**
- `teamId` (path, required) - Team identifier
- `userId` (path, required) - User identifier
**Request Body:**
```json
{ "role": "admin|billing_manager|member" }
```
**Status Codes:**
- `200` - Role updated
- `403` - Only admins can change member roles
---
### 10. Leave Team
**POST** `/teams/{teamId}/leave`
Allows a user to leave a team.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Status Codes:**
- `200` - Successfully left the team
- `400` - Cannot leave as the only admin
---
## Team Invite Management
### 11. Create Team Invite
**POST** `/teams/{teamId}/invites`
Creates a new invite code for the team.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Request Body (Optional):**
```json
{ "maxUses": "number (default: 1)", "expiresInDays": "number (default: 7)" }
```
**Response Schema:**
```json
{
"success": true,
"data": { "code": "string (e.g., T-1A2B3C4D5E6F)", "expiresAt": "date-time", "maxUses": "number" }
}
```
**Status Codes:**
- `200` - Invite created successfully
- `403` - Not a team member
---
### 12. List Team Invites
**GET** `/teams/{teamId}/invites`
Lists all invites for a team.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Status Codes:**
- `200` - Success
- `403` - User is not a member of the team
---
### 13. Revoke Team Invite
**DELETE** `/teams/{teamId}/invites/{inviteId}`
Revokes a team invite. **Admin or invite creator only**.
**Parameters:**
- `teamId` (path, required) - Team identifier
- `inviteId` (path, required) - Invite identifier
**Status Codes:**
- `200` - Invite successfully revoked
- `403` - Only admins or invite creator can revoke
- `404` - Invite does not exist
---
### 14. Join Team
**POST** `/teams/join`
Joins a team using an invite code.
**Request Body:**
```json
{ "code": "string (required, e.g., T-1A2B3C4D5E6F)" }
```
**Response Schema:**
```json
{ "success": true, "data": { "team": "string", "membership": "string" } }
```
**Status Codes:**
- `200` - Joined the team successfully
- `400` - Invite expired, max uses reached, or already a team member
- `404` - Invite code not found
---
## Team Billing Management
### 15. Purchase Team Subscription
**POST** `/teams/{teamId}/billing/purchase`
Purchases a subscription plan for the team. **Admin or billing manager only**.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Request Body:**
```json
{
"plan": "BASIC_MONTHLY|BASIC_YEARLY|PRO_MONTHLY|PRO_YEARLY",
"successUrl": "string (optional)",
"cancelUrl": "string (optional)"
}
```
**Status Codes:**
- `200` - Checkout session created successfully
- `403` - Only admins or billing managers can purchase plans
---
### 16. Get Team Subscription Plan
**GET** `/teams/{teamId}/billing/plan`
Retrieves the team's current subscription information.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Response Schema:**
```json
{
"success": true,
"data": {
"plan": "FREE|BASIC|PRO",
"hasActiveSubscription": "boolean",
"planExpiry": "date-time | null"
}
}
```
**Status Codes:**
- `200` - Success
- `401` - Unauthorized
---
### 17. Create Billing Portal Session
**POST** `/teams/{teamId}/billing/portal`
Creates a Stripe billing portal session for subscription management. **Admin or billing manager only**.
**Parameters:**
- `teamId` (path, required) - Team identifier
**Request Body (Optional):**
```json
{ "returnUrl": "string" }
```
**Response Schema:**
```json
{ "success": true, "data": { "url": "string" } }
```
**Status Codes:**
- `200` - Portal session created successfully
- `403` - Only admins or billing managers can create portal session
---
## Team Roles
### Role Hierarchy
1. **admin** - Full team management permissions
2. **billing_manager** - Billing and subscription management
3. **member** - Basic team member access
### Permission Matrix
| Action | Admin | Billing Manager | Member |
| -------------------- | ----- | --------------- | ------ |
| View team details | ✅ | ✅ | ✅ |
| Update team settings | ✅ | ❌ | ❌ |
| Delete team | ✅ | ❌ | ❌ |
| Add/remove members | ✅ | ❌ | ❌ |
| Change member roles | ✅ | ❌ | ❌ |
| Create invites | ✅ | ✅ | ✅ |
| Manage billing | ✅ | ✅ | ❌ |
| Leave team | ✅\* | ✅ | ✅ |
\*Admin cannot leave if they are the only admin
---
## Team Plans & Limits
### Plan Types
- **FREE** - Basic team functionality
- **BASIC** - Enhanced features and limits
- **PRO** - Full feature set and highest limits
### Usage Tracking
Teams have usage limits tracked through:
- `weeklyBudgetUsd` - Weekly spending budget
- `spentThisWeekUsd` - Current week spending
- `weekStartDate` - When the current week started
- `maxMembers` - Maximum team size
---
## Error Handling
### Common Error Responses
```json
{ "success": false, "error": "Error message description" }
```
### Authentication
All endpoints require a Bearer JWT token:
```
Authorization: Bearer <your-jwt-token>
```
### Rate Limiting
Standard API rate limits apply to all endpoints. Refer to main API documentation for specific limits.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

@@ -0,0 +1,375 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { ActionableItem, SnoozeOption } from '../../types/intelligence';
interface ActionableCardProps {
item: ActionableItem;
onComplete: (item: ActionableItem) => void;
onDismiss: (item: ActionableItem) => void;
onSnooze: (item: ActionableItem, duration: number) => void;
className?: string;
}
const SNOOZE_OPTIONS: SnoozeOption[] = [
{ label: '1 hour', duration: 60 * 60 * 1000 },
{ label: '6 hours', duration: 6 * 60 * 60 * 1000 },
{ label: '24 hours', duration: 24 * 60 * 60 * 1000 },
];
// Portal component for snooze dropdown to escape stacking contexts
interface SnoozeDropdownPortalProps {
isOpen: boolean;
buttonRef: React.RefObject<HTMLButtonElement | null>;
onClose: () => void;
onSnooze: (duration: number) => void;
}
function SnoozeDropdownPortal({ isOpen, buttonRef, onClose, onSnooze }: SnoozeDropdownPortalProps) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const dropdownRef = useRef<HTMLDivElement>(null);
// Calculate position based on button position
useEffect(() => {
if (isOpen && buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const dropdownWidth = 120;
// Position dropdown below and aligned to right edge of button
const left = Math.max(8, rect.right - dropdownWidth);
const top = rect.bottom + 4;
setPosition({ top, left });
}
}, [isOpen, buttonRef]);
// Handle click outside to close dropdown
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
// Don't close if clicking the button or dropdown itself
if (
buttonRef.current?.contains(target) ||
dropdownRef.current?.contains(target) ||
target.closest('[data-snooze-dropdown]')
) {
return;
}
onClose();
};
// Use capture phase to ensure we handle this before other click handlers
document.addEventListener('click', handleClickOutside, true);
return () => document.removeEventListener('click', handleClickOutside, true);
}, [isOpen, onClose, buttonRef]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div
ref={dropdownRef}
data-snooze-dropdown
className="fixed py-1 bg-stone-900 border border-stone-700 rounded-lg shadow-xl min-w-[120px] z-[9999] animate-fade-in"
style={{ top: position.top, left: position.left }}>
{SNOOZE_OPTIONS.map(option => (
<button
key={option.label}
onClick={() => onSnooze(option.duration)}
className="w-full text-left px-3 py-1.5 text-xs text-white hover:bg-stone-800 transition-colors cursor-pointer">
{option.label}
</button>
))}
</div>,
document.body
);
}
// Source icons for different actionable item types
const SOURCE_ICONS = {
email: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
),
calendar: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
),
telegram: (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
),
ai_insight: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
),
system: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
</svg>
),
trading: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
),
security: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
),
};
function formatTimeAgo(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7));
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes} min${minutes === 1 ? '' : 's'} ago`;
if (hours < 24) {
if (hours === 1) return '1 hour ago';
if (hours <= 6) return `${hours} hours ago`;
if (hours <= 12) return 'This morning';
return 'This afternoon';
}
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
if (weeks === 1) return '1 week ago';
return `${weeks} weeks ago`;
}
function isNewItem(date: Date): boolean {
const now = new Date();
const diff = now.getTime() - date.getTime();
return diff < 5 * 60 * 1000; // Less than 5 minutes old
}
export function ActionableCard({
item,
onComplete,
onDismiss,
onSnooze,
className = '',
}: ActionableCardProps) {
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
const snoozeButtonRef = useRef<HTMLButtonElement>(null);
const handleComplete = () => {
// Always let the parent handle completion logic
// The parent (Intelligence.tsx) ALWAYS opens ChatModal for ALL tick actions
onComplete(item);
};
const handleDismiss = () => {
// Always let the parent handle dismiss logic and show confirmation modal
// The parent (Intelligence.tsx) always shows confirmation for ALL dismiss actions
onDismiss(item);
};
const handleSnooze = (duration: number) => {
setIsAnimatingOut(true);
setTimeout(() => {
onSnooze(item, duration);
setShowSnoozeMenu(false);
}, 200);
};
// Priority styling
const priorityClasses = {
critical: 'border-coral-500/30 bg-coral-500/5',
important: 'border-amber-500/30 bg-amber-500/5',
normal: 'border-white/10 bg-white/[0.02]',
};
const priorityDotClasses = {
critical: 'bg-coral-400',
important: 'bg-amber-400',
normal: 'bg-sage-400',
};
const sourceIcon = item.icon || SOURCE_ICONS[item.source];
const isNew = isNewItem(item.createdAt);
const timeAgo = formatTimeAgo(item.createdAt);
return (
<div
className={`
relative group transition-all duration-200 ease-in-out
${isAnimatingOut ? 'opacity-0 translate-x-4 scale-95' : 'animate-fade-up'}
${className}
`}>
<div
className={`
relative p-4 rounded-xl border backdrop-blur-sm transition-all duration-200
hover:bg-white/[0.04] hover:border-white/20
${priorityClasses[item.priority]}
`}>
{/* Main content row */}
<div className="flex items-start gap-3">
{/* Icon */}
<div className="w-8 h-8 flex items-center justify-center text-white/70 flex-shrink-0 mt-0.5">
{sourceIcon}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-white leading-snug">{item.title}</h3>
{item.description && (
<p className="text-xs text-stone-400 mt-1 leading-relaxed">{item.description}</p>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Complete button */}
<button
onClick={handleComplete}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-sage-400 hover:bg-sage-400/10 transition-all duration-150"
title="Complete">
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
{/* Dismiss button */}
<button
onClick={handleDismiss}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-coral-400 hover:bg-coral-400/10 transition-all duration-150"
title="Dismiss">
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/* Snooze button */}
<div className="relative">
<button
ref={snoozeButtonRef}
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-amber-400 hover:bg-amber-400/10 transition-all duration-150"
title="Snooze">
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
</div>
</div>
</div>
{/* Meta info */}
<div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${priorityDotClasses[item.priority]}`} />
<span className="text-xs text-stone-500">{item.sourceLabel || item.source}</span>
</div>
<span className="text-xs text-stone-600"></span>
<span className="text-xs text-stone-500">{timeAgo}</span>
{isNew && (
<>
<span className="text-xs text-stone-600"></span>
<span className="text-xs bg-sage-500 text-white px-1.5 py-0.5 rounded-sm font-medium">
New
</span>
</>
)}
</div>
</div>
</div>
</div>
{/* Snooze dropdown portal - renders outside of any stacking context */}
<SnoozeDropdownPortal
isOpen={showSnoozeMenu}
buttonRef={snoozeButtonRef}
onClose={() => setShowSnoozeMenu(false)}
onSnooze={handleSnooze}
/>
</div>
);
}
@@ -0,0 +1,100 @@
import { useState } from 'react';
import type { ConfirmationModal as ConfirmationModalType } from '../../types/intelligence';
interface ConfirmationModalProps {
modal: ConfirmationModalType;
onClose: () => void;
}
export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
const [dontShowAgain, setDontShowAgain] = useState(false);
if (!modal.isOpen) return null;
const handleConfirm = () => {
modal.onConfirm();
onClose();
// TODO: Handle dontShowAgain preference storage
if (dontShowAgain) {
console.log('User chose to not show similar confirmations again');
}
};
const handleCancel = () => {
modal.onCancel();
onClose();
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 animate-fade-in"
onClick={handleCancel}>
<div
className="bg-stone-900 rounded-2xl max-w-md w-full shadow-large border border-stone-700/50 animate-slide-up"
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-3">
{modal.destructive && (
<div className="w-10 h-10 rounded-full bg-coral-500/10 flex items-center justify-center flex-shrink-0">
<svg
className="w-5 h-5 text-coral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
)}
<div className="flex-1">
<h2 className="text-lg font-semibold text-white">{modal.title}</h2>
<p className="text-sm text-stone-400 mt-1">{modal.message}</p>
</div>
</div>
</div>
{/* Don't show again option */}
{modal.showDontShowAgain && (
<div className="px-6 pb-2">
<label className="flex items-center gap-2 text-sm text-stone-400 cursor-pointer">
<input
type="checkbox"
checked={dontShowAgain}
onChange={e => setDontShowAgain(e.target.checked)}
className="rounded border-stone-600 bg-stone-800 text-primary-500 focus:ring-primary-500 focus:ring-offset-0"
/>
Don't show similar suggestions
</label>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 p-6 pt-4 border-t border-stone-700/50">
<button
onClick={handleCancel}
className="px-4 py-2 text-sm font-medium text-stone-400 hover:text-white rounded-lg hover:bg-stone-800 transition-colors">
{modal.cancelText || 'Cancel'}
</button>
<button
onClick={handleConfirm}
className={`
px-4 py-2 text-sm font-medium rounded-lg transition-colors
${
modal.destructive
? 'bg-coral-500 hover:bg-coral-600 text-white'
: 'bg-primary-500 hover:bg-primary-600 text-white'
}
`}>
{modal.confirmText || 'Confirm'}
</button>
</div>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useState } from 'react';
import type { ToastNotification } from '../../types/intelligence';
interface ToastProps {
notification: ToastNotification;
onRemove: (id: string) => void;
}
const TOAST_ICONS = {
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
};
const TOAST_STYLES = {
success: 'bg-sage-500 text-white',
error: 'bg-coral-500 text-white',
warning: 'bg-amber-500 text-white',
info: 'bg-primary-500 text-white',
};
export function Toast({ notification, onRemove }: ToastProps) {
const [isVisible, setIsVisible] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const handleRemove = useCallback(() => {
setIsExiting(true);
setTimeout(() => {
onRemove(notification.id);
}, 200);
}, [onRemove, notification.id]);
useEffect(() => {
// Animate in
const showTimer = setTimeout(() => setIsVisible(true), 50);
// Auto remove after duration
const duration = notification.duration || 4000;
const removeTimer = setTimeout(() => {
handleRemove();
}, duration);
return () => {
clearTimeout(showTimer);
clearTimeout(removeTimer);
};
}, [notification, handleRemove]);
const icon = TOAST_ICONS[notification.type];
const styles = TOAST_STYLES[notification.type];
return (
<div
className={`
transform transition-all duration-200 ease-in-out
${isVisible && !isExiting ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
${isExiting ? 'scale-95' : ''}
`}>
<div
className={`
flex items-center gap-3 p-4 rounded-lg shadow-large border backdrop-blur-sm
max-w-sm w-full
${styles}
`}>
{/* Icon */}
<div className="flex-shrink-0">{icon}</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium">{notification.title}</h4>
{notification.message && (
<p className="text-xs opacity-90 mt-1">{notification.message}</p>
)}
</div>
{/* Action button */}
{notification.action && (
<button
onClick={notification.action.handler}
className="text-xs font-medium underline hover:no-underline flex-shrink-0">
{notification.action.label}
</button>
)}
{/* Close button */}
<button
onClick={handleRemove}
className="flex-shrink-0 text-white/70 hover:text-white transition-colors">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
);
}
interface ToastContainerProps {
notifications: ToastNotification[];
onRemove: (id: string) => void;
}
export function ToastContainer({ notifications, onRemove }: ToastContainerProps) {
if (notifications.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
<div className="pointer-events-auto">
{notifications.map(notification => (
<Toast key={notification.id} notification={notification} onRemove={onRemove} />
))}
</div>
</div>
);
}
+19
View File
@@ -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';
+311
View File
@@ -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',
},
];
+140
View File
@@ -0,0 +1,140 @@
import type { ActionableItem, TimeGroup } from '../../types/intelligence';
/**
* Groups actionable items by time periods (Today, Yesterday, This Week, Older)
*/
export function groupItemsByTime(items: ActionableItem[]): TimeGroup[] {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
const oneWeekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const groups: Record<string, ActionableItem[]> = {
today: [],
yesterday: [],
thisWeek: [],
older: [],
};
items.forEach(item => {
const itemDate = new Date(item.createdAt);
const itemDateOnly = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate());
if (itemDateOnly >= today) {
groups.today.push(item);
} else if (itemDateOnly >= yesterday) {
groups.yesterday.push(item);
} else if (itemDateOnly >= oneWeekAgo) {
groups.thisWeek.push(item);
} else {
groups.older.push(item);
}
});
// Sort items within each group by priority and then by date (newest first)
const sortItems = (items: ActionableItem[]) => {
const priorityOrder = { critical: 0, important: 1, normal: 2 };
return items.sort((a, b) => {
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
if (priorityDiff !== 0) return priorityDiff;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
};
const timeGroups: TimeGroup[] = [];
if (groups.today.length > 0) {
timeGroups.push({ label: 'Today', items: sortItems(groups.today), count: groups.today.length });
}
if (groups.yesterday.length > 0) {
timeGroups.push({
label: 'Yesterday',
items: sortItems(groups.yesterday),
count: groups.yesterday.length,
});
}
if (groups.thisWeek.length > 0) {
timeGroups.push({
label: 'This Week',
items: sortItems(groups.thisWeek),
count: groups.thisWeek.length,
});
}
if (groups.older.length > 0) {
timeGroups.push({ label: 'Older', items: sortItems(groups.older), count: groups.older.length });
}
return timeGroups;
}
/**
* Filters items based on various criteria
*/
export function filterItems(
items: ActionableItem[],
options: { source?: string; priority?: string; status?: string; searchTerm?: string }
): ActionableItem[] {
let filtered = [...items];
if (options.source && options.source !== 'all') {
filtered = filtered.filter(item => item.source === options.source);
}
if (options.priority && options.priority !== 'all') {
filtered = filtered.filter(item => item.priority === options.priority);
}
if (options.status && options.status !== 'all') {
filtered = filtered.filter(item => item.status === options.status);
}
if (options.searchTerm) {
const term = options.searchTerm.toLowerCase();
filtered = filtered.filter(
item =>
item.title.toLowerCase().includes(term) ||
item.description?.toLowerCase().includes(term) ||
item.sourceLabel?.toLowerCase().includes(term)
);
}
return filtered;
}
/**
* Gets summary statistics for actionable items
*/
export function getItemStats(items: ActionableItem[]) {
const total = items.length;
const byPriority = items.reduce(
(acc, item) => {
acc[item.priority]++;
return acc;
},
{ critical: 0, important: 0, normal: 0 }
);
const bySource = items.reduce(
(acc, item) => {
acc[item.source] = (acc[item.source] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
const newItems = items.filter(item => {
const diff = Date.now() - item.createdAt.getTime();
return diff < 5 * 60 * 1000; // Less than 5 minutes
}).length;
const expiringSoon = items.filter(item => {
if (!item.expiresAt) return false;
const diff = item.expiresAt.getTime() - Date.now();
return diff < 24 * 60 * 60 * 1000 && diff > 0; // Expires within 24 hours
}).length;
return { total, byPriority, bySource, newItems, expiringSoon };
}
+7 -66
View File
@@ -4,7 +4,6 @@ import { skillManager } from '../../lib/skills/manager';
import { persistor } from '../../store';
import { clearToken } from '../../store/authSlice';
import { useAppDispatch } from '../../store/hooks';
import { IS_DEV } from '../../utils/config';
import { logout as tauriLogout } from '../../utils/tauriCommands';
import SettingsHeader from './components/SettingsHeader';
import SettingsMenuItem from './components/SettingsMenuItem';
@@ -96,47 +95,10 @@ const SettingsHome = () => {
// onClick: () => navigateToSettings("messaging"),
// dangerous: false,
// },
{
id: 'skills',
title: 'Skills',
description: 'Configure Slack, Discord, and other skills',
devOnly: true,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
/>
</svg>
),
onClick: () => navigateToSettings('skills'),
dangerous: false,
},
{
id: 'ai',
title: 'AI Configuration',
description: 'Configure SOUL persona and AI behavior',
devOnly: true,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
/>
</svg>
),
onClick: () => navigateToSettings('ai'),
dangerous: false,
},
// {
// id: 'agent-chat',
// title: 'Agent Chat',
// description: 'Send messages directly to your agent',
// devOnly: true,
// icon: (
// <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
// <path
@@ -259,39 +221,20 @@ const SettingsHome = () => {
dangerous: false,
},
{
id: 'tauri-commands',
title: 'Tauri Command Console',
description: 'Run Alphahuman Tauri commands for quick testing',
devOnly: true,
id: 'developer-options',
title: 'Developer Options',
description: 'Skills, AI config, Tauri console, and memory debug tools',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
),
onClick: () => navigateToSettings('tauri-commands'),
dangerous: false,
},
{
id: 'memory-debug',
title: 'Memory Debug',
description: 'Inspect memory documents, namespaces, and test query/recall',
devOnly: true,
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
/>
</svg>
),
onClick: () => navigateToSettings('memory-debug'),
onClick: () => navigateToSettings('developer-options'),
dangerous: false,
},
];
@@ -359,9 +302,7 @@ const SettingsHome = () => {
<div className="p-4 space-y-6">
{/* Main Settings */}
<div>
{mainMenuItems
.filter(item => !item.devOnly || IS_DEV)
.map((item, index, filtered) => (
{mainMenuItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
@@ -370,7 +311,7 @@ const SettingsHome = () => {
onClick={item.onClick}
dangerous={item.dangerous}
isFirst={index === 0}
isLast={index === filtered.length - 1}
isLast={index === mainMenuItems.length - 1}
/>
))}
</div>
@@ -12,6 +12,8 @@ export type SettingsRoute =
| 'team'
| 'team-members'
| 'team-invites'
| 'developer-options'
| 'skills'
| 'ai'
| 'tauri-commands'
| 'memory-debug';
@@ -45,6 +47,8 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/profile')) return 'profile';
if (path.includes('/settings/advanced')) return 'advanced';
if (path.includes('/settings/billing')) return 'billing';
if (path.includes('/settings/developer-options')) return 'developer-options';
if (path.includes('/settings/skills')) return 'skills';
if (path.includes('/settings/ai')) return 'ai';
if (path.includes('/settings/tauri-commands')) return 'tauri-commands';
if (path.includes('/settings/memory-debug')) return 'memory-debug';
@@ -71,6 +75,8 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
[navigate]
);
const developerSubRoutes: SettingsRoute[] = ['skills', 'ai', 'tauri-commands', 'memory-debug'];
const navigateBack = useCallback(() => {
if (currentRoute === 'home') {
navigate('/home');
@@ -85,6 +91,8 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
}
} else if (location.pathname.includes('/team/manage/')) {
navigate('/settings/team');
} else if (developerSubRoutes.includes(currentRoute as SettingsRoute)) {
navigate('/settings/developer-options');
} else {
navigate('/settings');
}
@@ -0,0 +1,100 @@
import SettingsHeader from '../components/SettingsHeader';
import SettingsMenuItem from '../components/SettingsMenuItem';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const developerItems = [
{
id: 'skills',
title: 'Skills',
description: 'Configure Slack, Discord, and other skills',
route: 'skills',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
/>
</svg>
),
},
{
id: 'ai',
title: 'AI Configuration',
description: 'Configure SOUL persona and AI behavior',
route: 'ai',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 3a.75.75 0 00-1.5 0v2.25H6a2.25 2.25 0 000 4.5h2.25V12H6a2.25 2.25 0 000 4.5h2.25V18a.75.75 0 001.5 0v-1.5H12V18a.75.75 0 001.5 0v-1.5H18a2.25 2.25 0 000-4.5h-4.5V9.75H18a2.25 2.25 0 000-4.5h-4.5V3a.75.75 0 00-1.5 0v2.25H9.75V3z"
/>
</svg>
),
},
{
id: 'tauri-commands',
title: 'Tauri Command Console',
description: 'Run Alphahuman Tauri commands for quick testing',
route: 'tauri-commands',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
/>
</svg>
),
},
{
id: 'memory-debug',
title: 'Memory Debug',
description: 'Inspect memory documents, namespaces, and test query/recall',
route: 'memory-debug',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m2 8H7a2 2 0 01-2-2V6a2 2 0 012-2h6l6 6v8a2 2 0 01-2 2z"
/>
</svg>
),
},
];
const DeveloperOptionsPanel = () => {
const { navigateToSettings, navigateBack } = useSettingsNavigation();
return (
<div className="overflow-hidden h-full flex flex-col z-10 relative">
<SettingsHeader title="Developer Options" showBackButton={true} onBack={navigateBack} />
<div className="flex-1 overflow-y-auto max-w-md mx-auto">
<div className="p-4">
<div>
{developerItems.map((item, index) => (
<SettingsMenuItem
key={item.id}
icon={item.icon}
title={item.title}
description={item.description}
onClick={() => navigateToSettings(item.route)}
isFirst={index === 0}
isLast={index === developerItems.length - 1}
/>
))}
</div>
</div>
</div>
</div>
);
};
export default DeveloperOptionsPanel;
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IS_DEV } from '../../../utils/config';
import {
memoryDeleteDocument,
memoryListDocuments,
@@ -82,7 +81,6 @@ const MemoryDebugPanel = () => {
}, [loadDocuments, loadNamespaces]);
useEffect(() => {
if (!IS_DEV) return;
void refreshAll();
}, [refreshAll]);
@@ -134,17 +132,6 @@ const MemoryDebugPanel = () => {
}
}, [maxChunks, namespaceInput]);
if (!IS_DEV) {
return (
<div className="overflow-hidden h-full flex flex-col">
<SettingsHeader title="Memory Debug" showBackButton={true} onBack={navigateBack} />
<div className="flex-1 overflow-y-auto p-4 text-sm text-stone-400">
Memory debug is available only in development mode.
</div>
</div>
);
}
return (
<div className="overflow-hidden h-full flex flex-col">
<SettingsHeader title="Memory Debug" showBackButton={true} onBack={navigateBack} />
+256
View File
@@ -0,0 +1,256 @@
import { useCallback, useEffect, useState } from 'react';
import { MOCK_ACTIONABLE_ITEMS } from '../components/intelligence/mockData';
import { intelligenceApi, type ConnectedTool } from '../services/intelligenceApi';
import type { ActionableItem, ActionableItemStatus } from '../types/intelligence';
import { transformBackendItemsToFrontend, transformBackendMessagesToFrontend } from '../utils/intelligenceTransforms';
/**
* Fallback implementation of Intelligence API hooks without React Query
* Used when React Query is not available
*/
interface UseActionableItemsResult {
data: ActionableItem[] | undefined;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
/**
* Hook for fetching actionable items (fallback version)
* TODO: Remove MOCK_ACTIONABLE_ITEMS once backend APIs are ready
*/
export const useActionableItems = (options?: {
refetchInterval?: number;
enabled?: boolean;
}): UseActionableItemsResult => {
const [data, setData] = useState<ActionableItem[] | undefined>(MOCK_ACTIONABLE_ITEMS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchItems = useCallback(async () => {
if (options?.enabled === false) return;
try {
setLoading(true);
setError(null);
const backendItems = await intelligenceApi.getActionableItems();
const items = transformBackendItemsToFrontend(backendItems);
setData(items.length > 0 ? items : MOCK_ACTIONABLE_ITEMS);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch items';
setError(errorMessage);
console.error('Failed to fetch actionable items:', err);
// TODO: Replace with actual data
setData(MOCK_ACTIONABLE_ITEMS);
} finally {
setLoading(false);
}
}, [options?.enabled]);
// Initial fetch
useEffect(() => {
fetchItems();
}, [fetchItems]);
// Set up refetch interval
useEffect(() => {
if (options?.refetchInterval) {
const interval = setInterval(fetchItems, options.refetchInterval);
return () => clearInterval(interval);
}
}, [options?.refetchInterval, fetchItems]);
return {
data,
loading,
error,
refetch: fetchItems,
};
};
interface UseUpdateActionableItemResult {
mutateAsync: (variables: {
itemId: string;
status: ActionableItemStatus;
}) => Promise<{ itemId: string; status: ActionableItemStatus; updatedAt: Date }>;
loading: boolean;
error: string | null;
}
/**
* Hook for updating actionable item status (fallback version)
*/
export const useUpdateActionableItem = (): UseUpdateActionableItemResult => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mutateAsync = useCallback(async (variables: {
itemId: string;
status: ActionableItemStatus;
}) => {
try {
setLoading(true);
setError(null);
await intelligenceApi.updateItemStatus(variables.itemId, variables.status);
return { ...variables, updatedAt: new Date() };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to update item';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
return {
mutateAsync,
loading,
error,
};
};
interface UseSnoozeActionableItemResult {
mutateAsync: (variables: {
itemId: string;
snoozeUntil: Date;
}) => Promise<{ itemId: string; snoozeUntil: Date; updatedAt: Date }>;
loading: boolean;
error: string | null;
}
/**
* Hook for snoozing actionable item (fallback version)
*/
export const useSnoozeActionableItem = (): UseSnoozeActionableItemResult => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mutateAsync = useCallback(async (variables: {
itemId: string;
snoozeUntil: Date;
}) => {
try {
setLoading(true);
setError(null);
await intelligenceApi.snoozeItem(variables.itemId, variables.snoozeUntil);
return { ...variables, updatedAt: new Date() };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to snooze item';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
return {
mutateAsync,
loading,
error,
};
};
interface UseChatSessionResult {
data: {
threadId: string;
messages: any[];
} | null;
loading: boolean;
error: string | null;
}
/**
* Hook for creating or getting chat session (fallback version)
*/
export const useChatSession = (itemId: string | null): UseChatSessionResult => {
const [data, setData] = useState<{ threadId: string; messages: any[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!itemId) return;
const fetchSession = async () => {
try {
setLoading(true);
setError(null);
const response = await intelligenceApi.getOrCreateThread(itemId);
setData({
threadId: response.threadId,
messages: transformBackendMessagesToFrontend(response.messages),
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create session';
setError(errorMessage);
console.error('Failed to create chat session:', err);
} finally {
setLoading(false);
}
};
fetchSession();
}, [itemId]);
return {
data,
loading,
error,
};
};
interface UseExecuteTaskResult {
mutateAsync: (variables: {
itemId: string;
connectedTools: ConnectedTool[];
}) => Promise<{
executionId: string;
sessionId: string;
status: string;
}>;
loading: boolean;
error: string | null;
}
/**
* Hook for executing tasks (fallback version)
*/
export const useExecuteTask = (): UseExecuteTaskResult => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const mutateAsync = useCallback(async (variables: {
itemId: string;
connectedTools: ConnectedTool[];
}) => {
try {
setLoading(true);
setError(null);
const result = await intelligenceApi.executeTask(variables.itemId, variables.connectedTools);
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to execute task';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
return {
mutateAsync,
loading,
error,
};
};
// Export query key utilities for consistency
export const intelligenceKeys = {
all: ['intelligence'] as const,
items: () => [...intelligenceKeys.all, 'items'] as const,
item: (id: string) => [...intelligenceKeys.all, 'item', id] as const,
thread: (itemId: string) => [...intelligenceKeys.all, 'thread', itemId] as const,
messages: (threadId: string) => [...intelligenceKeys.all, 'messages', threadId] as const,
execution: (executionId: string) => [...intelligenceKeys.all, 'execution', executionId] as const,
};
+388
View File
@@ -0,0 +1,388 @@
import { useCallback, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { socketService } from '../services/socketService';
import type { RootState } from '../store';
import { addMessage, setExecutionResult, setTyping, updateExecutionProgress } from '../store/intelligenceSlice';
import { createChatMessage } from '../utils/intelligenceTransforms';
import { emitViaRustSocket } from '../utils/tauriSocket';
/**
* WebSocket event payloads for Intelligence system
*/
interface ProcessMessagePayload {
message: string;
threadId: string;
sessionId?: string;
context?: Record<string, any>;
}
interface AgentResponsePayload {
message: string;
threadId: string;
sessionId?: string;
shouldExecute?: boolean;
executionPlan?: any;
metadata?: Record<string, any>;
}
interface ExecutionProgressPayload {
executionId: string;
sessionId: string;
step: {
id: string;
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
timestamp: string;
};
progress: Array<{
id: string;
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
timestamp?: Date;
}>;
}
interface ExecutionCompletePayload {
executionId: string;
sessionId: string;
status: 'completed' | 'failed';
result?: any;
error?: string;
artifacts?: Array<{
type: string;
url: string;
title: string;
description?: string;
}>;
}
interface ChatInitPayload {
tools: any[];
threadId: string;
sessionId?: string;
context?: Record<string, any>;
}
/**
* Check if running in Tauri environment for socket routing
*/
function isTauri(): boolean {
try {
return (window as any)?.__TAURI__ !== undefined;
} catch {
return false;
}
}
/**
* Hook for Intelligence WebSocket integration
* Handles real-time communication for chat and task execution
*/
export const useIntelligenceSocket = () => {
const dispatch = useDispatch();
const socket = socketService.getSocket();
const eventHandlersRegistered = useRef(false);
// Get current socket connection status
const isSocketConnected = useSelector((state: RootState) => {
const userId = state.user.user?._id || '__pending__';
return state.socket.byUser[userId]?.status === 'connected';
});
/**
* Send message to AI agent via WebSocket
*/
const sendMessage = useCallback(async (payload: ProcessMessagePayload) => {
if (isTauri()) {
// Use Rust socket for Tauri environment
emitViaRustSocket('processMessageForUser', payload);
} else if (socket?.connected) {
socket.emit('processMessageForUser', payload);
} else {
console.warn('Cannot send message - socket not connected');
throw new Error('Socket not connected');
}
}, [socket]);
/**
* Initialize chat session with tools
*/
const sendChatInit = useCallback(async (payload: ChatInitPayload) => {
if (isTauri()) {
emitViaRustSocket('chat:init', payload);
} else if (socket?.connected) {
socket.emit('chat:init', payload);
} else {
console.warn('Cannot initialize chat - socket not connected');
throw new Error('Socket not connected');
}
}, [socket]);
/**
* Send typing indicator
*/
const sendTyping = useCallback((threadId: string, isTyping: boolean) => {
const payload = { threadId, isTyping };
if (isTauri()) {
emitViaRustSocket('chat:typing', payload);
} else if (socket?.connected) {
socket.emit('chat:typing', payload);
}
}, [socket]);
/**
* Register WebSocket event handlers
*/
const registerEventHandlers = useCallback(() => {
if (!socket || eventHandlersRegistered.current) return;
// Agent response handler
const handleAgentResponse = (data: AgentResponsePayload) => {
console.log('Intelligence: Received agent response', {
threadId: data.threadId,
hasMessage: !!data.message,
shouldExecute: data.shouldExecute,
});
if (data.message && data.threadId) {
const aiMessage = createChatMessage(data.message, 'ai');
dispatch(addMessage({
threadId: data.threadId,
message: aiMessage,
}));
}
// Stop typing indicator
dispatch(setTyping({
threadId: data.threadId,
isTyping: false,
}));
// Handle execution trigger
if (data.shouldExecute && data.executionPlan) {
console.log('Intelligence: Execution requested', {
threadId: data.threadId,
plan: data.executionPlan,
});
// Execution will be handled by the component
}
};
// Execution progress handler
const handleExecutionProgress = (data: ExecutionProgressPayload) => {
console.log('Intelligence: Execution progress', {
executionId: data.executionId,
step: data.step?.label,
status: data.step?.status,
});
if (data.progress) {
dispatch(updateExecutionProgress({
executionId: data.executionId,
progress: data.progress,
}));
}
};
// Execution complete handler
const handleExecutionComplete = (data: ExecutionCompletePayload) => {
console.log('Intelligence: Execution complete', {
executionId: data.executionId,
status: data.status,
hasResult: !!data.result,
hasArtifacts: !!data.artifacts?.length,
});
dispatch(setExecutionResult({
executionId: data.executionId,
result: data.result,
status: data.status,
error: data.error,
}));
// Send completion message if we have artifacts
if (data.artifacts?.length && data.sessionId) {
// This would need the threadId - we'd need to track execution to thread mapping
// For now, we'll let the component handle the completion message
console.log('Intelligence: Task completed with artifacts', {
artifactCount: data.artifacts.length,
sessionId: data.sessionId,
});
}
};
// Typing indicator handler
const handleTyping = (data: { threadId: string; isTyping: boolean }) => {
dispatch(setTyping({
threadId: data.threadId,
isTyping: data.isTyping,
}));
};
// Register all handlers
socket.on('agentResponse', handleAgentResponse);
socket.on('execution:step_progress', handleExecutionProgress);
socket.on('execution:complete', handleExecutionComplete);
socket.on('chat:typing', handleTyping);
eventHandlersRegistered.current = true;
// Return cleanup function
return () => {
socket.off('agentResponse', handleAgentResponse);
socket.off('execution:step_progress', handleExecutionProgress);
socket.off('execution:complete', handleExecutionComplete);
socket.off('chat:typing', handleTyping);
eventHandlersRegistered.current = false;
};
}, [socket, dispatch]);
/**
* Register event handlers when socket is available
*/
useEffect(() => {
if (socket && isSocketConnected) {
const cleanup = registerEventHandlers();
return cleanup;
}
}, [socket, isSocketConnected, registerEventHandlers]);
/**
* Cleanup on unmount
*/
useEffect(() => {
return () => {
eventHandlersRegistered.current = false;
};
}, []);
return {
// Connection status
isConnected: isSocketConnected,
// Message sending
sendMessage,
sendChatInit,
sendTyping,
// Utility functions
isReady: isSocketConnected && !!socket,
};
};
/**
* Hook for managing Intelligence WebSocket connection lifecycle
*/
export const useIntelligenceSocketManager = () => {
const token = useSelector((state: RootState) => state.auth.token);
const isConnected = useSelector((state: RootState) => {
const userId = state.user.user?._id || '__pending__';
return state.socket.byUser[userId]?.status === 'connected';
});
/**
* Initialize Intelligence socket connection
*/
const connect = useCallback(() => {
if (token && !isConnected) {
console.log('Intelligence: Initializing socket connection');
socketService.connect(token);
}
}, [token, isConnected]);
/**
* Disconnect Intelligence socket
*/
const disconnect = useCallback(() => {
console.log('Intelligence: Disconnecting socket');
socketService.disconnect();
}, []);
/**
* Auto-connect when token is available
*/
useEffect(() => {
if (token && !isConnected) {
connect();
}
}, [token, isConnected, connect]);
return {
connect,
disconnect,
isConnected,
isReady: isConnected && !!token,
};
};
/**
* Hook for Intelligence-specific event subscriptions
*/
export const useIntelligenceEvents = () => {
const socket = socketService.getSocket();
/**
* Subscribe to agent responses for a specific thread
*/
const onAgentResponse = useCallback((
threadId: string,
callback: (data: AgentResponsePayload) => void
) => {
if (!socket) return () => {};
const handler = (data: AgentResponsePayload) => {
if (data.threadId === threadId) {
callback(data);
}
};
socket.on('agentResponse', handler);
return () => socket.off('agentResponse', handler);
}, [socket]);
/**
* Subscribe to execution progress for a specific execution
*/
const onExecutionProgress = useCallback((
executionId: string,
callback: (data: ExecutionProgressPayload) => void
) => {
if (!socket) return () => {};
const handler = (data: ExecutionProgressPayload) => {
if (data.executionId === executionId) {
callback(data);
}
};
socket.on('execution:step_progress', handler);
return () => socket.off('execution:step_progress', handler);
}, [socket]);
/**
* Subscribe to execution completion for a specific execution
*/
const onExecutionComplete = useCallback((
executionId: string,
callback: (data: ExecutionCompletePayload) => void
) => {
if (!socket) return () => {};
const handler = (data: ExecutionCompletePayload) => {
if (data.executionId === executionId) {
callback(data);
}
};
socket.on('execution:complete', handler);
return () => socket.off('execution:complete', handler);
}, [socket]);
return {
onAgentResponse,
onExecutionProgress,
onExecutionComplete,
};
};
+316
View File
@@ -0,0 +1,316 @@
/**
* Chat tools initialization for Intelligence system.
*
* Provides functionality to gather all active skills' tools and send them
* to the backend when a chat session is initialized, enabling AI to understand
* available capabilities for task execution.
*/
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import debug from 'debug';
import type { ConnectedTool } from '../../services/intelligenceApi';
import { socketService } from '../../services/socketService';
import { store } from '../../store';
import { emitViaRustSocket } from '../../utils/tauriSocket';
import { transformMCPToConnectedTools } from '../../utils/intelligenceTransforms';
import type { MCPTool } from '../mcp';
import { deriveConnectionStatus } from '../skills/hooks';
// Chat tools logger using debug package
const chatToolsLog = debug('chat:tools');
const chatToolsWarn = debug('chat:tools:warn');
// Enable logging in development
if (import.meta.env.DEV || import.meta.env.MODE === 'development') {
debug.enable('chat:tools*');
}
/**
* Check if running in Tauri environment
*/
function isTauri(): boolean {
try {
return coreIsTauri();
} catch {
return false;
}
}
/**
* Interface for chat initialization payload
*/
export interface ChatInitPayload {
tools: MCPTool[];
connectedTools: ConnectedTool[];
sessionId?: string;
threadId?: string;
timestamp: number;
}
/**
* Gather all tools from active skills and send them to the backend
* when initializing a chat session.
*
* This function:
* 1. Retrieves all skills from Redux store
* 2. Filters for active/ready skills with completed setup
* 3. Extracts and formats tool definitions in both MCP and Connected formats
* 4. Emits 'chat:init' event via appropriate socket method
*
* @param sessionId - Optional chat session identifier
* @param threadId - Optional thread identifier for chat session
* @param useConnectedFormat - Whether to send tools in Connected format (default: true)
*/
export function initializeChatWithTools(
sessionId?: string,
threadId?: string,
useConnectedFormat: boolean = true
): void {
try {
const state = store.getState();
const skills = state.skills.skills;
const skillStates = state.skills.skillStates;
chatToolsLog('Initializing chat with tools', {
sessionId,
threadId,
useConnectedFormat,
totalSkills: Object.keys(skills).length,
});
const activeTools: MCPTool[] = [];
// Process each skill to extract active tools
for (const [skillId, skill] of Object.entries(skills)) {
// Derive connection status using existing logic
const connectionStatus = deriveConnectionStatus(
skill.status,
skill.setupComplete,
skillStates[skillId]
);
// Only include tools from skills that are ready and properly connected
const isSkillActive =
(skill.status === 'ready' || skill.status === 'running') &&
skill.setupComplete &&
(connectionStatus === 'connected' || connectionStatus === 'connecting'); // Include connecting state for gradual initialization
if (isSkillActive && skill.tools?.length) {
chatToolsLog('Processing tools for active skill', {
skillId,
skillName: skill.manifest.name,
toolCount: skill.tools.length,
status: skill.status,
connectionStatus,
});
// Transform skill tools to MCP format with skill prefix
for (const tool of skill.tools) {
activeTools.push({
name: `${skillId}__${tool.name}`,
description: `${skill.manifest.name}: ${tool.description}`,
inputSchema: tool.inputSchema,
});
}
} else {
chatToolsLog('Skipping inactive skill', {
skillId,
skillName: skill.manifest.name,
status: skill.status,
setupComplete: skill.setupComplete,
connectionStatus,
toolCount: skill.tools?.length || 0,
});
}
}
// Create tools breakdown by skill for detailed logging
const toolsBySkill: Record<string, { skillName: string; tools: string[] }> = {};
activeTools.forEach(tool => {
const [skillId] = tool.name.split('__');
const skill = skills[skillId];
if (skill) {
if (!toolsBySkill[skillId]) {
toolsBySkill[skillId] = { skillName: skill.manifest.name, tools: [] };
}
toolsBySkill[skillId].tools.push(tool.name.split('__')[1]);
}
});
// Transform to connected tools format if requested
const connectedTools = useConnectedFormat ? transformMCPToConnectedTools(activeTools) : [];
// Prepare chat initialization payload
const payload: ChatInitPayload = {
tools: activeTools,
connectedTools,
sessionId,
threadId,
timestamp: Date.now()
};
// Detailed logging of available tools
chatToolsLog('🛠️ AVAILABLE TOOLS FOR CHAT INITIALIZATION', {
sessionId,
threadId,
totalTools: activeTools.length,
totalConnectedTools: connectedTools.length,
totalSkills: Object.keys(toolsBySkill).length,
useConnectedFormat,
});
// Log tools breakdown by skill
Object.entries(toolsBySkill).forEach(([skillId, { skillName, tools }]) => {
chatToolsLog(`📦 ${skillName} (${skillId}):`, { toolCount: tools.length, tools });
});
// Log complete tools list with descriptions
if (activeTools.length > 0) {
chatToolsLog(
'📋 Complete Tools List:',
activeTools.map(tool => ({
name: tool.name,
description: tool.description.slice(0, 80) + (tool.description.length > 80 ? '...' : ''),
}))
);
} else {
chatToolsLog('⚠️ No active tools available - chat will have no tool capabilities');
}
// Emit via appropriate socket method based on environment
if (isTauri()) {
chatToolsLog('Emitting chat:init via Rust socket');
emitViaRustSocket('chat:init', payload);
} else {
chatToolsLog('Emitting chat:init via web socket');
if (socketService.isConnected()) {
socketService.emit('chat:init', payload);
} else {
chatToolsWarn('Socket not connected - chat initialization may be delayed');
// Could potentially queue the initialization for when socket connects
}
}
chatToolsLog('Chat tools initialization completed', {
sessionId,
threadId,
toolCount: activeTools.length,
connectedToolCount: connectedTools.length,
environment: isTauri() ? 'tauri' : 'web',
});
} catch (error) {
chatToolsWarn('Failed to initialize chat with tools', {
sessionId,
error: error instanceof Error ? error.message : String(error),
});
// Don't throw - allow chat to continue without tools if needed
// The backend should handle empty tools gracefully
}
}
/**
* Initialize Intelligence chat session with both MCP and Connected tools formats
* @param sessionId - Chat session identifier
* @param threadId - Thread identifier for chat session
* @param connectedTools - Pre-transformed connected tools (optional)
*/
export function initializeIntelligenceChatSession(
sessionId: string,
threadId: string,
connectedTools?: ConnectedTool[]
): void {
try {
const mcpTools = getCurrentActiveTools();
const finalConnectedTools = connectedTools || transformMCPToConnectedTools(mcpTools);
const payload = {
tools: finalConnectedTools, // Intelligence system expects Connected format
sessionId,
threadId,
timestamp: Date.now(),
};
chatToolsLog('Intelligence: Initializing chat session', {
sessionId,
threadId,
toolCount: finalConnectedTools.length,
});
// Emit via appropriate socket method based on environment
if (isTauri()) {
chatToolsLog('Intelligence: Emitting chat:init via Rust socket');
emitViaRustSocket('chat:init', payload);
} else {
chatToolsLog('Intelligence: Emitting chat:init via web socket');
if (socketService.isConnected()) {
socketService.emit('chat:init', payload);
} else {
chatToolsWarn('Intelligence: Socket not connected - chat initialization may be delayed');
}
}
} catch (error) {
chatToolsWarn('Intelligence: Failed to initialize chat session', {
sessionId,
threadId,
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Get current active tools without emitting (for debugging/display purposes)
*/
export function getCurrentActiveTools(): MCPTool[] {
try {
const state = store.getState();
const skills = state.skills.skills;
const skillStates = state.skills.skillStates;
const activeTools: MCPTool[] = [];
for (const [skillId, skill] of Object.entries(skills)) {
const connectionStatus = deriveConnectionStatus(
skill.status,
skill.setupComplete,
skillStates[skillId]
);
const isSkillActive =
(skill.status === 'ready' || skill.status === 'running') &&
skill.setupComplete &&
(connectionStatus === 'connected' || connectionStatus === 'connecting');
if (isSkillActive && skill.tools?.length) {
for (const tool of skill.tools) {
activeTools.push({
name: `${skillId}__${tool.name}`,
description: `${skill.manifest.name}: ${tool.description}`,
inputSchema: tool.inputSchema,
});
}
}
}
return activeTools;
} catch (error) {
chatToolsWarn('Failed to get current active tools', {
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Get current active tools in Connected format for Intelligence system
*/
export function getCurrentConnectedTools(): ConnectedTool[] {
try {
const mcpTools = getCurrentActiveTools();
return transformMCPToConnectedTools(mcpTools);
} catch (error) {
chatToolsWarn('Failed to get current connected tools', {
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
+17 -49
View File
@@ -44,7 +44,6 @@ import {
setLastViewed,
setPanelWidth,
setSelectedThread,
updateMessagesForThread,
} from '../store/threadSlice';
import type { ThreadMessage } from '../types/thread';
@@ -344,31 +343,19 @@ const Conversations = () => {
},
onError: event => {
if (event.thread_id !== selectedThreadIdRef.current) return;
if (event.error_type !== 'cancelled') {
setSendError(event.message);
}
setIsSending(false);
setActiveToolCall(null);
dispatch(setActiveThread(null));
// Remove the optimistic user message on error
dispatch((innerDispatch, getState) => {
const state = getState() as {
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
};
const persistedMessages = state.thread.messagesByThreadId[event.thread_id] || [];
const lastUserIdx = [...persistedMessages]
.reverse()
.findIndex(m => m.sender === 'user');
if (lastUserIdx !== -1) {
const actualIdx = persistedMessages.length - 1 - lastUserIdx;
const updated = persistedMessages.filter((_, i) => i !== actualIdx);
innerDispatch(updateMessagesForThread({ threadId: event.thread_id, messages: updated }));
if (event.thread_id === selectedThreadIdRef.current) {
innerDispatch(setSelectedThread(event.thread_id));
}
}
});
if (event.error_type !== 'cancelled') {
dispatch(
addInferenceResponse({
content: 'Something went wrong — please try again.',
threadId: event.thread_id,
})
);
} else {
dispatch(setActiveThread(null));
}
},
}).then(fn => {
if (mounted) cleanup = fn;
@@ -633,32 +620,13 @@ const Conversations = () => {
// Pass the original sending thread ID to ensure response goes to correct thread
dispatch(addInferenceResponse({ content: finalContent, threadId: sendingThreadId }));
} catch (err) {
// Remove the user message from persistent storage on error
// We'll use a thunk-like approach to access current state
dispatch((innerDispatch, getState) => {
const state = getState() as {
thread: { messagesByThreadId: Record<string, ThreadMessage[]> };
};
const persistedMessages = state.thread.messagesByThreadId[sendingThreadId] || [];
const currentMessages = persistedMessages.filter(m => m.id !== userMessage.id);
innerDispatch(
updateMessagesForThread({ threadId: sendingThreadId, messages: currentMessages })
);
// Also remove from current view if this is the selected thread
if (sendingThreadId === selectedThreadId) {
innerDispatch(setSelectedThread(sendingThreadId));
}
});
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: unknown }).error)
: 'Failed to get response';
setSendError(msg);
// Clear active thread on error
dispatch(setActiveThread(null));
} catch {
dispatch(
addInferenceResponse({
content: 'Something went wrong — please try again.',
threadId: sendingThreadId,
})
);
} finally {
clearTimeout(safetyTimeout);
setIsSending(false);
+375 -20
View File
@@ -1,31 +1,386 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { ActionableCard } from '../components/intelligence/ActionableCard';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import { ToastContainer } from '../components/intelligence/Toast';
import { filterItems, getItemStats, groupItemsByTime } from '../components/intelligence/utils';
import {
useActionableItems,
useSnoozeActionableItem,
useUpdateActionableItem,
} from '../hooks/useIntelligenceApiFallback';
import { useIntelligenceSocket, useIntelligenceSocketManager } from '../hooks/useIntelligenceSocket';
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
import type { RootState } from '../store';
import {
setSourceFilter,
setSearchFilter,
} from '../store/intelligenceSlice';
import type {
ActionableItem,
ActionableItemStatus,
ConfirmationModal as ConfirmationModalType,
ToastNotification,
} from '../types/intelligence';
export default function Intelligence() {
const dispatch = useDispatch();
const { aiStatus } = useIntelligenceStats();
// Redux state
const intelligenceState = useSelector((state: RootState) => state.intelligence);
const { filters } = intelligenceState;
// API hooks
const {
data: apiItems,
loading: itemsLoading,
error: itemsError,
refetch: refetchItems,
} = useActionableItems();
const { mutateAsync: updateItemStatus } = useUpdateActionableItem();
const { mutateAsync: snoozeItem } = useSnoozeActionableItem();
// Socket integration
const socketManager = useIntelligenceSocketManager();
const { isConnected: socketConnected } = useIntelligenceSocket();
// Local state for UI
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const [confirmationModal, setConfirmationModal] = useState<ConfirmationModalType>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
onCancel: () => {},
});
// Use API data or fallback to empty array
const items = apiItems || [];
// Initialize socket connection
useEffect(() => {
if (!socketConnected) {
socketManager.connect();
}
}, [socketConnected, socketManager]);
// Handle API errors with toast notifications
useEffect(() => {
if (itemsError) {
addToast({
type: 'error',
title: 'Failed to load items',
message: typeof itemsError === 'string' ? itemsError : 'Unable to fetch actionable items',
});
}
}, [itemsError]);
// Filter and group items
const filteredItems = useMemo(() => {
const activeItems = items.filter(item => item.status === 'active');
return filterItems(activeItems, {
source: filters.source,
priority: filters.priority,
searchTerm: filters.search,
});
}, [items, filters.source, filters.priority, filters.search]);
const timeGroups = useMemo(() => {
return groupItemsByTime(filteredItems);
}, [filteredItems]);
const stats = useMemo(() => {
return getItemStats(filteredItems);
}, [filteredItems]);
// Toast utilities
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
setToasts(prev => [...prev, newToast]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
}, []);
// Item action handlers with real backend integration
const handleUpdateItemStatus = useCallback(async (itemId: string, status: ActionableItemStatus) => {
try {
await updateItemStatus({ itemId, status });
// Success toast
let message = '';
switch (status) {
case 'completed':
message = 'Task marked as completed';
break;
case 'dismissed':
message = 'Task dismissed';
break;
case 'active':
message = 'Task reactivated';
break;
default:
message = 'Status updated';
}
addToast({
type: 'success',
title: 'Status Updated',
message,
});
} catch (error) {
console.error('Failed to update item status:', error);
addToast({
type: 'error',
title: 'Update Failed',
message: error instanceof Error ? error.message : 'Failed to update item status',
});
}
}, [updateItemStatus]);
const handleComplete = useCallback(async (item: ActionableItem) => {
await handleUpdateItemStatus(item.id, 'completed');
}, [handleUpdateItemStatus]);
const handleDismiss = useCallback(
(item: ActionableItem) => {
// Always show confirmation modal for ALL dismiss actions
setConfirmationModal({
isOpen: true,
title: 'Dismiss item?',
message: `Are you sure you want to dismiss "${item.title}"?`,
confirmText: 'Dismiss',
cancelText: 'Cancel',
destructive: item.priority === 'critical',
showDontShowAgain: !item.requiresConfirmation,
onConfirm: async () => {
try {
await handleUpdateItemStatus(item.id, 'dismissed');
// Add undo action to toast
addToast({
type: 'info',
title: 'Dismissed',
message: item.title.length > 40 ? `${item.title.substring(0, 40)}...` : item.title,
action: {
label: 'Undo',
handler: () => handleUpdateItemStatus(item.id, 'active'),
},
});
} catch (error) {
console.error('Failed to dismiss item:', error);
}
},
onCancel: () => {},
});
},
[handleUpdateItemStatus, addToast]
);
const handleSnooze = useCallback(
async (item: ActionableItem, duration: number) => {
try {
const snoozeUntil = new Date(Date.now() + duration);
await snoozeItem({ itemId: item.id, snoozeUntil });
const hours = Math.round(duration / (1000 * 60 * 60));
addToast({
type: 'info',
title: 'Snoozed',
message: `Reminded in ${hours === 1 ? '1 hour' : `${hours} hours`}`,
});
} catch (error) {
console.error('Failed to snooze item:', error);
addToast({
type: 'error',
title: 'Snooze Failed',
message: 'Failed to snooze item. Please try again.',
});
}
},
[snoozeItem, addToast]
);
// Combined AI and socket status indicator
const systemStatus = socketConnected && aiStatus === 'ready' ? 'ready' :
itemsLoading ? 'loading' :
!socketConnected ? 'disconnected' : aiStatus;
const systemStatusLabel =
systemStatus === 'ready' ? 'System Ready' :
systemStatus === 'loading' ? 'Loading...' :
systemStatus === 'disconnected' ? 'Connecting...' :
systemStatus === 'initializing' ? 'Initializing...' :
systemStatus === 'error' ? 'System Error' : 'System Idle';
const systemStatusDot =
systemStatus === 'ready' ? 'bg-sage-400' :
systemStatus === 'loading' ? 'bg-amber-400 animate-pulse' :
systemStatus === 'disconnected' ? 'bg-amber-400 animate-pulse' :
systemStatus === 'initializing' ? 'bg-amber-400 animate-pulse' :
systemStatus === 'error' ? 'bg-coral-400' : 'bg-stone-600';
return (
<div className="min-h-full relative">
<div className="relative z-10 min-h-full flex flex-col items-center justify-center">
<div className="max-w-md mx-auto text-center px-6">
{/* Icon */}
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl bg-white/[0.05] border border-white/[0.08] flex items-center justify-center">
<svg className="w-8 h-8 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</div>
<div className="relative z-10 min-h-full flex flex-col">
<div className="flex-1 p-6">
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Intelligence</h1>
{stats.total > 0 && (
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
{stats.total}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${systemStatusDot}`} />
<span className="text-xs text-stone-400">{systemStatusLabel}</span>
</div>
</div>
<h1 className="text-2xl font-bold text-white mb-2">Intelligence</h1>
<p className="text-stone-400 text-sm mb-6">
AI-powered insights, memory, and analytics are coming soon.
</p>
{/* Filters */}
<div className="flex items-center gap-3 mb-6 animate-fade-up">
<div className="flex-1">
<input
type="text"
placeholder="Search actionable items..."
value={filters.search}
onChange={e => dispatch(setSearchFilter(e.target.value))}
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50 transition-colors"
/>
</div>
<select
value={filters.source}
onChange={e => dispatch(setSourceFilter(e.target.value as any))}
className="px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500/50 transition-colors">
<option value="all">All Sources</option>
<option value="email">Email</option>
<option value="calendar">Calendar</option>
<option value="telegram">Telegram</option>
<option value="ai_insight">AI Insights</option>
<option value="system">System</option>
<option value="trading">Trading</option>
<option value="security">Security</option>
</select>
</div>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary-500/10 border border-primary-500/20">
<div className="w-2 h-2 rounded-full bg-primary-400 animate-pulse" />
<span className="text-xs font-medium text-primary-400">Coming Soon</span>
{/* Content */}
{itemsLoading ? (
/* Loading State */
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin"></div>
</div>
<h2 className="text-lg font-semibold text-white mb-2">Loading Intelligence...</h2>
<p className="text-stone-400 text-sm">Fetching your actionable items</p>
</div>
) : itemsError ? (
/* Error State */
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-coral-500/10">
<svg
className="w-8 h-8 text-coral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-white mb-2">Unable to load items</h2>
<p className="text-stone-400 text-sm mb-4">
{typeof itemsError === 'string' ? itemsError : 'Something went wrong'}
</p>
<button
onClick={() => refetchItems()}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white text-sm rounded-lg transition-colors"
>
Try Again
</button>
</div>
) : timeGroups.length === 0 ? (
/* Empty State */
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<svg
className="w-8 h-8 text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-white mb-2">All caught up!</h2>
<p className="text-stone-400 text-sm">
{filters.search || filters.source !== 'all'
? 'No items match your current filters.'
: 'No actionable items at the moment. Great work!'}
</p>
</div>
) : (
/* Time Groups */
<div className="space-y-6">
{timeGroups.map((group, groupIndex) => (
<div
key={group.label}
className="animate-fade-up"
style={{ animationDelay: `${groupIndex * 50}ms` }}>
{/* Group Header */}
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-white opacity-80">{group.label}</h2>
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
{group.count}
</div>
</div>
{/* Items */}
<div className="space-y-3">
{group.items.map((item, itemIndex) => (
<div
key={item.id}
className="animate-fade-up"
style={{ animationDelay: `${groupIndex * 50 + itemIndex * 25}ms` }}>
<ActionableCard
item={item}
onComplete={handleComplete}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
/>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Toast notifications */}
<ToastContainer notifications={toasts} onRemove={removeToast} />
{/* Confirmation modal */}
<ConfirmationModal
modal={confirmationModal}
onClose={() => setConfirmationModal(prev => ({ ...prev, isOpen: false }))}
/>
</div>
);
}
+2
View File
@@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import AdvancedPanel from '../components/settings/panels/AdvancedPanel';
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
import AIPanel from '../components/settings/panels/AIPanel';
import DeveloperOptionsPanel from '../components/settings/panels/DeveloperOptionsPanel';
import BillingPanel from '../components/settings/panels/BillingPanel';
import ConnectionsPanel from '../components/settings/panels/ConnectionsPanel';
import MessagingPanel from '../components/settings/panels/MessagingPanel';
@@ -37,6 +38,7 @@ const Settings = () => {
<Route path="team/manage/:teamId/invites" element={<TeamInvitesPanel />} />
<Route path="team/members" element={<TeamMembersPanel />} />
<Route path="team/invites" element={<TeamInvitesPanel />} />
<Route path="developer-options" element={<DeveloperOptionsPanel />} />
<Route path="tauri-commands" element={<TauriCommandsPanel />} />
<Route path="memory-debug" element={<MemoryDebugPanel />} />
</Routes>
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useIntelligenceSocketManager } from '../hooks/useIntelligenceSocket';
import { setInitialized, setConnectionStatus } from '../store/intelligenceSlice';
/**
* Intelligence context for managing system-wide Intelligence state
*/
interface IntelligenceContextValue {
isInitialized: boolean;
isConnected: boolean;
initialize: () => void;
}
const IntelligenceContext = createContext<IntelligenceContextValue | null>(null);
interface IntelligenceProviderProps {
children: ReactNode;
}
/**
* Intelligence Provider - manages Intelligence system initialization and state
*/
export function IntelligenceProvider({ children }: IntelligenceProviderProps) {
const dispatch = useDispatch();
const socketManager = useIntelligenceSocketManager();
// Initialize Intelligence system
useEffect(() => {
dispatch(setInitialized(true));
dispatch(setConnectionStatus(socketManager.isConnected ? 'connected' : 'connecting'));
}, [dispatch, socketManager.isConnected]);
// Monitor connection status
useEffect(() => {
if (socketManager.isConnected) {
dispatch(setConnectionStatus('connected'));
} else {
dispatch(setConnectionStatus('connecting'));
}
}, [dispatch, socketManager.isConnected]);
const contextValue: IntelligenceContextValue = {
isInitialized: true,
isConnected: socketManager.isConnected,
initialize: socketManager.connect,
};
return (
<IntelligenceContext.Provider value={contextValue}>
{children}
</IntelligenceContext.Provider>
);
}
/**
* Hook to access Intelligence context
*/
export function useIntelligenceContext() {
const context = useContext(IntelligenceContext);
if (!context) {
throw new Error('useIntelligenceContext must be used within IntelligenceProvider');
}
return context;
}
+177
View File
@@ -0,0 +1,177 @@
import type { ActionableItemStatus } from '../types/intelligence';
import { apiClient } from './apiClient';
/**
* Backend API response types for Intelligence system
*/
export interface BackendActionableItem {
id: string;
title: string;
description?: string;
source: string;
priority: string;
status: string;
createdAt: string;
updatedAt: string;
expiresAt?: string;
snoozeUntil?: string;
actionable: boolean;
requiresConfirmation?: boolean;
hasComplexAction?: boolean;
dismissedAt?: string;
completedAt?: string;
reminderCount?: number;
// Backend-specific fields
threadId?: string;
executionStatus?: 'idle' | 'running' | 'completed' | 'failed';
currentSessionId?: string;
}
export interface BackendChatMessage {
id: string;
content: string;
role: 'user' | 'assistant';
timestamp: string;
threadId: string;
}
export interface BackendThreadResponse {
threadId: string;
messages: BackendChatMessage[];
}
export interface BackendExecutionResponse {
executionId: string;
sessionId: string;
status: 'started' | 'running' | 'completed' | 'failed';
}
export interface ConnectedTool {
name: string;
description: string;
parameters: Record<string, any>;
skillId: string;
enabled: boolean;
}
/**
* Intelligence API Service - handles all REST API calls for the Intelligence system
*/
export class IntelligenceApiService {
/**
* Get all actionable items from the backend
*/
async getActionableItems(): Promise<BackendActionableItem[]> {
try {
const response = await apiClient.get<{ items: BackendActionableItem[] }>('/telegram/actionable-items');
return response.items || [];
} catch (error) {
console.error('Failed to fetch actionable items:', error);
throw error;
}
}
/**
* Update the status of an actionable item
*/
async updateItemStatus(itemId: string, status: ActionableItemStatus): Promise<void> {
try {
await apiClient.patch(`/actionable-items/${itemId}`, { status });
} catch (error) {
console.error('Failed to update item status:', error);
throw error;
}
}
/**
* Snooze an actionable item until a specific time
*/
async snoozeItem(itemId: string, snoozeUntil: Date): Promise<void> {
try {
await apiClient.patch(`/actionable-items/${itemId}`, {
status: 'snoozed',
snoozeUntil: snoozeUntil.toISOString()
});
} catch (error) {
console.error('Failed to snooze item:', error);
throw error;
}
}
/**
* Get or create a conversation thread for an actionable item
*/
async getOrCreateThread(itemId: string): Promise<BackendThreadResponse> {
try {
const response = await apiClient.get<BackendThreadResponse>(`/${itemId}/thread`);
return response;
} catch (error) {
console.error('Failed to get or create thread:', error);
throw error;
}
}
/**
* Get chat history for a specific thread
*/
async getChatHistory(threadId: string): Promise<BackendChatMessage[]> {
try {
const response = await apiClient.get<{ messages: BackendChatMessage[] }>(`/threads/${threadId}/messages`);
return response.messages || [];
} catch (error) {
console.error('Failed to get chat history:', error);
throw error;
}
}
/**
* Start task execution for an actionable item with connected tools
*/
async executeTask(itemId: string, connectedTools: ConnectedTool[]): Promise<BackendExecutionResponse> {
try {
const response = await apiClient.post<BackendExecutionResponse>(`/${itemId}/execute`, {
connectedTools
});
return response;
} catch (error) {
console.error('Failed to execute task:', error);
throw error;
}
}
/**
* Get execution status for a specific execution ID
*/
async getExecutionStatus(executionId: string): Promise<{
status: 'running' | 'completed' | 'failed';
progress?: any[];
result?: any;
}> {
try {
const response = await apiClient.get(`/executions/${executionId}/status`);
return response as {
status: 'running' | 'completed' | 'failed';
progress?: any[];
result?: any;
};
} catch (error) {
console.error('Failed to get execution status:', error);
throw error;
}
}
/**
* Cancel a running execution
*/
async cancelExecution(executionId: string): Promise<void> {
try {
await apiClient.post(`/executions/${executionId}/cancel`);
} catch (error) {
console.error('Failed to cancel execution:', error);
throw error;
}
}
}
// Export singleton instance
export const intelligenceApi = new IntelligenceApiService();
+2
View File
@@ -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,
},
+505
View File
@@ -0,0 +1,505 @@
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { intelligenceApi } from '../services/intelligenceApi';
import type {
ActionableItem,
ActionableItemSource,
ActionableItemStatus,
ChatMessage
} from '../types/intelligence';
import { transformBackendItemsToFrontend, transformBackendMessagesToFrontend } from '../utils/intelligenceTransforms';
/**
* Chat session state for managing individual conversations
*/
export interface ChatSessionState {
threadId: string;
itemId: string;
messages: ChatMessage[];
isTyping: boolean;
lastMessageId?: string;
isConnected: boolean;
}
/**
* Execution state for tracking task progress
*/
export interface ExecutionState {
executionId: string;
sessionId: string;
itemId: string;
status: 'idle' | 'starting' | 'running' | 'completed' | 'failed';
progress: Array<{
id: string;
label: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
timestamp?: Date;
}>;
result?: any;
error?: string;
}
/**
* Intelligence Redux state
*/
export interface IntelligenceState {
// Actionable Items
items: ActionableItem[];
loading: boolean;
error: string | null;
lastUpdate: Date | null;
// Chat Sessions
activeSessions: Record<string, ChatSessionState>;
currentChatSession: string | null;
// Execution Management
activeExecutions: Record<string, ExecutionState>;
// UI State
filters: {
source: ActionableItemSource | 'all';
priority: 'critical' | 'important' | 'normal' | 'all';
search: string;
};
// System state
initialized: boolean;
connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
}
const initialState: IntelligenceState = {
// Actionable Items
items: [],
loading: false,
error: null,
lastUpdate: null,
// Chat Sessions
activeSessions: {},
currentChatSession: null,
// Execution Management
activeExecutions: {},
// UI State
filters: {
source: 'all',
priority: 'all',
search: '',
},
// System state
initialized: false,
connectionStatus: 'disconnected',
};
// Async thunks for API operations
/**
* Fetch actionable items from backend
*/
export const fetchActionableItems = createAsyncThunk(
'intelligence/fetchActionableItems',
async (_, { rejectWithValue }) => {
try {
const backendItems = await intelligenceApi.getActionableItems();
return transformBackendItemsToFrontend(backendItems);
} catch (error) {
return rejectWithValue(
error && typeof error === 'object' && 'error' in error
? error.error
: 'Failed to fetch actionable items'
);
}
}
);
/**
* Update item status
*/
export const updateItemStatus = createAsyncThunk(
'intelligence/updateItemStatus',
async (
{ itemId, status }: { itemId: string; status: ActionableItemStatus },
{ rejectWithValue }
) => {
try {
await intelligenceApi.updateItemStatus(itemId, status);
return { itemId, status, updatedAt: new Date() };
} catch (error) {
return rejectWithValue(
error && typeof error === 'object' && 'error' in error
? error.error
: 'Failed to update item status'
);
}
}
);
/**
* Snooze item until specific time
*/
export const snoozeItem = createAsyncThunk(
'intelligence/snoozeItem',
async (
{ itemId, snoozeUntil }: { itemId: string; snoozeUntil: Date },
{ rejectWithValue }
) => {
try {
await intelligenceApi.snoozeItem(itemId, snoozeUntil);
return { itemId, snoozeUntil, updatedAt: new Date() };
} catch (error) {
return rejectWithValue(
error && typeof error === 'object' && 'error' in error
? error.error
: 'Failed to snooze item'
);
}
}
);
/**
* Get or create chat session
*/
export const createChatSession = createAsyncThunk(
'intelligence/createChatSession',
async ({ itemId }: { itemId: string }, { rejectWithValue }) => {
try {
const threadResponse = await intelligenceApi.getOrCreateThread(itemId);
const messages = transformBackendMessagesToFrontend(threadResponse.messages);
return {
threadId: threadResponse.threadId,
itemId,
messages,
};
} catch (error) {
return rejectWithValue(
error && typeof error === 'object' && 'error' in error
? error.error
: 'Failed to create chat session'
);
}
}
);
/**
* Execute task with connected tools
*/
export const executeTask = createAsyncThunk(
'intelligence/executeTask',
async (
{ itemId, connectedTools }: { itemId: string; connectedTools: any[] },
{ rejectWithValue }
) => {
try {
const response = await intelligenceApi.executeTask(itemId, connectedTools);
return {
executionId: response.executionId,
sessionId: response.sessionId,
itemId,
status: response.status,
};
} catch (error) {
return rejectWithValue(
error && typeof error === 'object' && 'error' in error
? error.error
: 'Failed to execute task'
);
}
}
);
/**
* Intelligence slice
*/
export const intelligenceSlice = createSlice({
name: 'intelligence',
initialState,
reducers: {
// System actions
setInitialized: (state, action: PayloadAction<boolean>) => {
state.initialized = action.payload;
},
setConnectionStatus: (
state,
action: PayloadAction<'disconnected' | 'connecting' | 'connected' | 'error'>
) => {
state.connectionStatus = action.payload;
},
// Items actions
setItems: (state, action: PayloadAction<ActionableItem[]>) => {
state.items = action.payload;
state.lastUpdate = new Date();
},
addItem: (state, action: PayloadAction<ActionableItem>) => {
state.items.unshift(action.payload);
state.lastUpdate = new Date();
},
removeItem: (state, action: PayloadAction<string>) => {
state.items = state.items.filter(item => item.id !== action.payload);
state.lastUpdate = new Date();
},
// Filters actions
setSourceFilter: (state, action: PayloadAction<ActionableItemSource | 'all'>) => {
state.filters.source = action.payload;
},
setPriorityFilter: (state, action: PayloadAction<'critical' | 'important' | 'normal' | 'all'>) => {
state.filters.priority = action.payload;
},
setSearchFilter: (state, action: PayloadAction<string>) => {
state.filters.search = action.payload;
},
// Chat session actions
setChatSession: (
state,
action: PayloadAction<{
threadId: string;
itemId: string;
messages?: ChatMessage[];
}>
) => {
const { threadId, itemId, messages = [] } = action.payload;
state.activeSessions[threadId] = {
threadId,
itemId,
messages,
isTyping: false,
isConnected: true,
};
state.currentChatSession = threadId;
},
addMessage: (
state,
action: PayloadAction<{
threadId: string;
message: ChatMessage;
}>
) => {
const { threadId, message } = action.payload;
const session = state.activeSessions[threadId];
if (session) {
session.messages.push(message);
session.lastMessageId = message.id;
}
},
setTyping: (
state,
action: PayloadAction<{
threadId: string;
isTyping: boolean;
}>
) => {
const { threadId, isTyping } = action.payload;
const session = state.activeSessions[threadId];
if (session) {
session.isTyping = isTyping;
}
},
closeChatSession: (state, action: PayloadAction<string>) => {
const threadId = action.payload;
delete state.activeSessions[threadId];
if (state.currentChatSession === threadId) {
state.currentChatSession = null;
}
},
setCurrentChatSession: (state, action: PayloadAction<string | null>) => {
state.currentChatSession = action.payload;
},
// Execution actions
setExecution: (
state,
action: PayloadAction<{
executionId: string;
execution: ExecutionState;
}>
) => {
const { executionId, execution } = action.payload;
state.activeExecutions[executionId] = execution;
},
updateExecutionProgress: (
state,
action: PayloadAction<{
executionId: string;
progress: ExecutionState['progress'];
}>
) => {
const { executionId, progress } = action.payload;
const execution = state.activeExecutions[executionId];
if (execution) {
execution.progress = progress;
execution.status = 'running';
}
},
setExecutionResult: (
state,
action: PayloadAction<{
executionId: string;
result: any;
status: 'completed' | 'failed';
error?: string;
}>
) => {
const { executionId, result, status, error } = action.payload;
const execution = state.activeExecutions[executionId];
if (execution) {
execution.result = result;
execution.status = status;
execution.error = error;
}
},
clearExecution: (state, action: PayloadAction<string>) => {
const executionId = action.payload;
delete state.activeExecutions[executionId];
},
// Error handling
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
// Fetch actionable items
builder
.addCase(fetchActionableItems.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchActionableItems.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
state.lastUpdate = new Date();
state.error = null;
})
.addCase(fetchActionableItems.rejected, (state, action) => {
state.loading = false;
state.error = action.payload as string;
});
// Update item status
builder
.addCase(updateItemStatus.fulfilled, (state, action) => {
const { itemId, status, updatedAt } = action.payload;
const item = state.items.find(item => item.id === itemId);
if (item) {
item.status = status;
item.updatedAt = updatedAt;
// Set completion/dismissal timestamps
if (status === 'completed') {
item.completedAt = updatedAt;
} else if (status === 'dismissed') {
item.dismissedAt = updatedAt;
}
}
state.lastUpdate = new Date();
})
.addCase(updateItemStatus.rejected, (state, action) => {
state.error = action.payload as string;
});
// Snooze item
builder
.addCase(snoozeItem.fulfilled, (state, action) => {
const { itemId, snoozeUntil, updatedAt } = action.payload;
const item = state.items.find(item => item.id === itemId);
if (item) {
item.status = 'snoozed';
item.snoozeUntil = snoozeUntil;
item.updatedAt = updatedAt;
item.reminderCount = (item.reminderCount || 0) + 1;
}
state.lastUpdate = new Date();
})
.addCase(snoozeItem.rejected, (state, action) => {
state.error = action.payload as string;
});
// Create chat session
builder
.addCase(createChatSession.fulfilled, (state, action) => {
const { threadId, itemId, messages } = action.payload;
state.activeSessions[threadId] = {
threadId,
itemId,
messages,
isTyping: false,
isConnected: true,
};
state.currentChatSession = threadId;
// Update item with thread information
const item = state.items.find(item => item.id === itemId);
if (item) {
item.threadId = threadId;
}
})
.addCase(createChatSession.rejected, (state, action) => {
state.error = action.payload as string;
});
// Execute task
builder
.addCase(executeTask.fulfilled, (state, action) => {
const { executionId, sessionId, itemId, status } = action.payload;
state.activeExecutions[executionId] = {
executionId,
sessionId,
itemId,
status: status === 'started' ? 'running' : 'idle',
progress: [],
};
// Update item execution status
const item = state.items.find(item => item.id === itemId);
if (item) {
item.executionStatus = 'running';
item.currentSessionId = sessionId;
}
})
.addCase(executeTask.rejected, (state, action) => {
state.error = action.payload as string;
});
},
});
export const {
setInitialized,
setConnectionStatus,
setItems,
addItem,
removeItem,
setSourceFilter,
setPriorityFilter,
setSearchFilter,
setChatSession,
addMessage,
setTyping,
closeChatSession,
setCurrentChatSession,
setExecution,
updateExecutionProgress,
setExecutionResult,
clearExecution,
clearError,
} = intelligenceSlice.actions;
export default intelligenceSlice.reducer;
+7 -5
View File
@@ -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
+390
View File
@@ -0,0 +1,390 @@
/**
* Intelligence Chat API Types
*
* TypeScript definitions for backend integration
* These types ensure consistency between frontend and backend implementations
*/
// ===== Core Types =====
export type ConversationFlow =
| 'discovery'
| 'planning'
| 'confirmation'
| 'execution'
| 'completion'
| 'auto_close';
export type MessageType =
| 'message'
| 'plan'
| 'progress'
| 'completion'
| 'discovery_question'
| 'confirmation_request';
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
export type SessionStatus = 'active' | 'executing' | 'completed' | 'failed';
export type WorkflowType =
| 'meeting_prep'
| 'email_response'
| 'system_task'
| 'document_analysis'
| 'calendar_management';
// ===== Request/Response Types =====
export interface CreateChatSessionRequest {
actionable_item_id: string;
user_id: string;
context: { item_type: WorkflowType; metadata: Record<string, any> };
}
export interface CreateChatSessionResponse {
success: true;
data: {
session_id: string;
initial_message: {
content: string;
type: MessageType;
options: string[];
context: Record<string, any>;
};
expected_flow: ConversationFlow[];
};
}
export interface ChatMessage {
id: string;
content: string;
sender: 'ai' | 'user';
timestamp: string; // ISO 8601
type: MessageType;
metadata?: Record<string, any>;
}
export interface ChatSessionDetails {
session_id: string;
status: SessionStatus;
current_flow: ConversationFlow;
messages: ChatMessage[];
context: Record<string, any>;
execution_id?: string;
}
export interface SendMessageRequest {
content: string;
sender: 'user';
current_flow: ConversationFlow;
context: Record<string, any>;
}
export interface SendMessageResponse {
success: true;
data: {
ai_response: {
content: string;
type: MessageType;
next_flow: ConversationFlow;
options: string[];
metadata: Record<string, any>;
};
should_execute: boolean;
execution_plan?: ExecutionPlan;
};
}
// ===== Execution Types =====
export interface ExecutionStep {
id: string;
label: string;
status: TaskStatus;
estimated_duration: number;
dependencies: string[];
started_at?: string; // ISO 8601
completed_at?: string; // ISO 8601
progress_percentage?: number;
result?: Record<string, any>;
}
export interface ExecutionPlan {
id: string;
steps: ExecutionStep[];
estimated_total_duration: number;
requirements: { gmail_access?: boolean; notion_access?: boolean; calendar_access?: boolean };
}
export interface StartExecutionRequest {
execution_plan_id: string;
confirmed: true;
modifications?: Record<string, any>;
}
export interface StartExecutionResponse {
success: true;
data: {
execution_id: string;
status: 'started';
estimated_duration: number;
steps: ExecutionStep[];
};
}
export interface ExecutionStatusResponse {
success: true;
data: {
execution_id: string;
status: 'running' | 'completed' | 'failed';
current_step?: { id: string; label: string; status: TaskStatus; progress_percentage: number };
completed_steps: string[];
results?: ExecutionResults;
};
}
export interface ExecutionResults {
summary: string;
artifacts: Artifact[];
metrics: {
total_duration: number;
emails_processed?: number;
documents_created?: number;
apis_called?: number;
};
}
export interface Artifact {
type: 'notion_doc' | 'email_draft' | 'calendar_event' | 'document' | 'link';
title: string;
url: string;
created_at: string; // ISO 8601
metadata: Record<string, any>;
}
// ===== WebSocket Event Types =====
export interface WebSocketMessage<T = any> {
type: string;
data: T;
timestamp?: string; // ISO 8601
}
// Tool definition for chat initialization
export interface ChatTool {
name: string;
description: string;
inputSchema: { type: 'object'; properties: Record<string, any>; required?: string[] };
}
// Client → Server Events
export interface ChatInitEvent {
type: 'chat:init';
data: { tools: ChatTool[]; sessionId?: string; timestamp: number };
}
export interface AuthenticateEvent {
type: 'authenticate';
data: { token: string; session_id: string };
}
export interface JoinSessionEvent {
type: 'join_session';
data: { session_id: string };
}
// Server → Client Events
export interface AuthenticatedEvent {
type: 'authenticated';
data: { user_id: string; session_id: string };
}
export interface ExecutionStepProgressEvent {
type: 'execution_step_progress';
data: {
session_id: string;
execution_id: string;
step_id: string;
status: TaskStatus;
progress_percentage: number;
message: string;
timestamp: string; // ISO 8601
};
}
export interface ExecutionCompleteEvent {
type: 'execution_complete';
data: {
session_id: string;
execution_id: string;
status: 'completed' | 'failed';
results: ExecutionResults;
};
}
export interface AIThinkingEvent {
type: 'ai_thinking';
data: { session_id: string; message: string; estimated_time: number };
}
export interface ErrorEvent {
type: 'error';
data: {
session_id: string;
error_code: ErrorCode;
message: string;
details: Record<string, any>;
retry_after?: number;
};
}
// ===== Error Types =====
export type ErrorCode =
| 'SESSION_NOT_FOUND'
| 'EXECUTION_FAILED'
| 'RATE_LIMITED'
| 'INVALID_INPUT'
| 'SOURCE_UNAVAILABLE'
| 'AI_UNAVAILABLE'
| 'TIMEOUT'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'INTERNAL_ERROR';
export interface APIError {
code: ErrorCode;
message: string;
details: Record<string, any>;
}
// ===== Standard API Response Envelope =====
export interface APIResponse<T = any> {
success: boolean;
data: T | null;
error: APIError | null;
meta: {
timestamp: string; // ISO 8601
request_id: string;
rate_limit: {
remaining: number;
reset_at: string; // ISO 8601
};
};
}
// ===== Meeting Preparation Specific Types =====
export interface MeetingPrepWorkflow {
workflow_type: 'meeting_preparation';
inputs: {
meeting_title: string;
participant: string;
time_context: string;
document_sources: ('gmail' | 'notion' | 'calendar')[];
output_format: 'notion_doc' | 'email_summary' | 'both';
};
processing_steps: MeetingPrepStep[];
}
export interface MeetingPrepStep {
step: 'fetch_gmail' | 'access_notion' | 'analyze_context' | 'consolidate_docs' | 'generate_link';
action: string;
params: Record<string, any>;
}
// ===== Service Integration Types =====
export interface ExternalServiceConfig {
gmail?: { enabled: boolean; scopes: string[]; rate_limit: number };
notion?: { enabled: boolean; workspace_id: string; rate_limit: number };
calendar?: { enabled: boolean; calendar_ids: string[] };
}
export interface ServiceAuthStatus {
gmail: 'connected' | 'disconnected' | 'error';
notion: 'connected' | 'disconnected' | 'error';
calendar: 'connected' | 'disconnected' | 'error';
}
// ===== Performance & Monitoring Types =====
export interface PerformanceMetrics {
response_time: number;
execution_time: number;
external_api_calls: number;
tokens_used: number;
cost_estimate: number;
}
export interface SessionMetrics {
session_id: string;
user_id: string;
started_at: string; // ISO 8601
ended_at?: string; // ISO 8601
total_messages: number;
execution_count: number;
performance: PerformanceMetrics;
satisfaction_score?: number;
}
// ===== Type Guards =====
export function isWebSocketMessage<T>(obj: any): obj is WebSocketMessage<T> {
return typeof obj === 'object' && obj !== null && typeof obj.type === 'string' && 'data' in obj;
}
export function isAPIResponse<T>(obj: any): obj is APIResponse<T> {
return (
typeof obj === 'object' &&
obj !== null &&
typeof obj.success === 'boolean' &&
('data' in obj || 'error' in obj) &&
'meta' in obj
);
}
export function isExecutionStepProgressEvent(obj: any): obj is ExecutionStepProgressEvent {
return (
isWebSocketMessage(obj) &&
obj.type === 'execution_step_progress' &&
typeof obj.data === 'object' &&
obj.data !== null &&
'step_id' in obj.data &&
'progress_percentage' in obj.data
);
}
// ===== Utility Types =====
export type ChatEventHandler<T = any> = (event: WebSocketMessage<T>) => void;
export interface ChatClientConfig {
websocket_url: string;
api_base_url: string;
auth_token: string;
retry_attempts: number;
timeout_ms: number;
}
// ===== Frontend State Types =====
export interface ChatState {
session: ChatSessionDetails | null;
messages: ChatMessage[];
currentFlow: ConversationFlow;
isExecuting: boolean;
executionProgress: ExecutionStep[];
isConnected: boolean;
lastError: APIError | null;
}
export interface ChatActions {
sendMessage: (content: string) => Promise<void>;
startExecution: (planId: string) => Promise<void>;
connect: () => Promise<void>;
disconnect: () => void;
clearError: () => void;
}
+110
View File
@@ -0,0 +1,110 @@
// Intelligence System Types
// Actionable items and AI insights for the Intelligence page
import type React from 'react';
export type ActionableItemSource =
| 'email'
| 'calendar'
| 'telegram'
| 'ai_insight'
| 'system'
| 'trading'
| 'security';
export type ActionableItemPriority = 'critical' | 'important' | 'normal';
export type ActionableItemStatus = 'active' | 'dismissed' | 'completed' | 'snoozed';
export interface ActionableItem {
id: string;
title: string;
description?: string;
source: ActionableItemSource;
priority: ActionableItemPriority;
status: ActionableItemStatus;
createdAt: Date;
updatedAt: Date;
expiresAt?: Date;
snoozeUntil?: Date;
// Action metadata
actionable: boolean;
requiresConfirmation?: boolean;
hasComplexAction?: boolean;
// Visual presentation
icon?: React.ReactElement;
sourceLabel?: string;
// Interaction tracking
dismissedAt?: Date;
completedAt?: Date;
reminderCount?: number;
// Backend integration fields
threadId?: string;
executionStatus?: 'idle' | 'running' | 'completed' | 'failed';
currentSessionId?: string;
}
export interface ActionableItemAction {
type: 'complete' | 'dismiss' | 'snooze';
timestamp: Date;
itemId: string;
metadata?: Record<string, unknown>;
}
export interface TimeGroup {
label: string;
items: ActionableItem[];
count: number;
}
export interface IntelligencePageState {
items: ActionableItem[];
loading: boolean;
error: string | null;
lastUpdate: Date | null;
// UI state
showCompleted: boolean;
filter: ActionableItemSource | 'all';
}
// Snooze time options
export type SnoozeOption = {
label: string;
duration: number; // milliseconds
customTime?: Date;
};
// Toast notification types
export interface ToastNotification {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message?: string;
duration?: number;
action?: { label: string; handler: () => void };
}
// Confirmation modal data
export interface ConfirmationModal {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
destructive?: boolean;
showDontShowAgain?: boolean;
}
// Chat message type
export interface ChatMessage {
id: string;
content: string;
sender: 'user' | 'ai';
timestamp: Date;
}
+249
View File
@@ -0,0 +1,249 @@
import type { MCPTool } from '../lib/mcp';
import type {
BackendActionableItem,
BackendChatMessage,
ConnectedTool
} from '../services/intelligenceApi';
import type {
ActionableItem,
ActionableItemPriority,
ActionableItemSource,
ActionableItemStatus,
ChatMessage
} from '../types/intelligence';
/**
* Transform backend actionable item to frontend format
*/
export function transformBackendItemToFrontend(backendItem: BackendActionableItem): ActionableItem {
return {
id: backendItem.id,
title: backendItem.title,
description: backendItem.description,
source: backendItem.source as ActionableItemSource,
priority: backendItem.priority as ActionableItemPriority,
status: backendItem.status as ActionableItemStatus,
createdAt: new Date(backendItem.createdAt),
updatedAt: new Date(backendItem.updatedAt),
expiresAt: backendItem.expiresAt ? new Date(backendItem.expiresAt) : undefined,
snoozeUntil: backendItem.snoozeUntil ? new Date(backendItem.snoozeUntil) : undefined,
actionable: backendItem.actionable,
requiresConfirmation: backendItem.requiresConfirmation,
hasComplexAction: backendItem.hasComplexAction,
dismissedAt: backendItem.dismissedAt ? new Date(backendItem.dismissedAt) : undefined,
completedAt: backendItem.completedAt ? new Date(backendItem.completedAt) : undefined,
reminderCount: backendItem.reminderCount,
// Backend integration fields
threadId: backendItem.threadId,
executionStatus: backendItem.executionStatus,
currentSessionId: backendItem.currentSessionId,
};
}
/**
* Transform multiple backend items to frontend format
*/
export function transformBackendItemsToFrontend(backendItems: BackendActionableItem[]): ActionableItem[] {
return backendItems.map(transformBackendItemToFrontend);
}
/**
* Transform backend chat message to frontend format
*/
export function transformBackendMessageToFrontend(backendMessage: BackendChatMessage): ChatMessage {
return {
id: backendMessage.id,
content: backendMessage.content,
sender: backendMessage.role === 'user' ? 'user' : 'ai',
timestamp: new Date(backendMessage.timestamp),
};
}
/**
* Transform multiple backend messages to frontend format
*/
export function transformBackendMessagesToFrontend(backendMessages: BackendChatMessage[]): ChatMessage[] {
return backendMessages.map(transformBackendMessageToFrontend);
}
/**
* Transform frontend chat message to backend format
*/
export function transformFrontendMessageToBackend(
message: ChatMessage,
threadId: string
): Omit<BackendChatMessage, 'id'> {
return {
content: message.content,
role: message.sender === 'user' ? 'user' : 'assistant',
timestamp: message.timestamp.toISOString(),
threadId,
};
}
/**
* Transform MCP tools to connected tools format for backend
*/
export function transformMCPToConnectedTools(mcpTools: MCPTool[]): ConnectedTool[] {
return mcpTools.map(tool => {
const [skillId, toolName] = tool.name.split('__');
return {
name: toolName || tool.name,
description: tool.description,
parameters: tool.inputSchema || {},
skillId: skillId || 'unknown',
enabled: true,
};
});
}
/**
* Transform connected tools back to MCP format
*/
export function transformConnectedToolsToMCP(connectedTools: ConnectedTool[]): MCPTool[] {
return connectedTools.map(tool => ({
name: `${tool.skillId}__${tool.name}`,
description: tool.description,
inputSchema: {
type: 'object',
properties: tool.parameters || {},
},
}));
}
/**
* Validate actionable item data from backend
*/
export function validateBackendItem(item: any): item is BackendActionableItem {
return (
typeof item === 'object' &&
item !== null &&
typeof item.id === 'string' &&
typeof item.title === 'string' &&
typeof item.source === 'string' &&
typeof item.priority === 'string' &&
typeof item.status === 'string' &&
typeof item.createdAt === 'string' &&
typeof item.updatedAt === 'string' &&
typeof item.actionable === 'boolean'
);
}
/**
* Create a new chat message in frontend format
*/
export function createChatMessage(
content: string,
sender: 'user' | 'ai',
id?: string
): ChatMessage {
return {
id: id || `${sender}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
content,
sender,
timestamp: new Date(),
};
}
/**
* Map execution status to user-friendly labels
*/
export function getExecutionStatusLabel(status?: string): string {
switch (status) {
case 'idle':
return 'Ready';
case 'running':
return 'In Progress';
case 'completed':
return 'Completed';
case 'failed':
return 'Failed';
default:
return 'Unknown';
}
}
/**
* Get priority display information
*/
export function getPriorityInfo(priority: ActionableItemPriority): {
label: string;
className: string;
color: string;
} {
switch (priority) {
case 'critical':
return {
label: 'Critical',
className: 'text-coral-400 bg-coral-500/10',
color: 'coral',
};
case 'important':
return {
label: 'Important',
className: 'text-amber-400 bg-amber-500/10',
color: 'amber',
};
case 'normal':
return {
label: 'Normal',
className: 'text-sage-400 bg-sage-500/10',
color: 'sage',
};
}
}
/**
* Get source display information
*/
export function getSourceInfo(source: ActionableItemSource): {
label: string;
icon: string;
className: string;
} {
switch (source) {
case 'email':
return {
label: 'Email',
icon: '📧',
className: 'text-blue-400 bg-blue-500/10',
};
case 'calendar':
return {
label: 'Calendar',
icon: '📅',
className: 'text-green-400 bg-green-500/10',
};
case 'telegram':
return {
label: 'Telegram',
icon: '💬',
className: 'text-blue-400 bg-blue-500/10',
};
case 'ai_insight':
return {
label: 'AI Insight',
icon: '🤖',
className: 'text-purple-400 bg-purple-500/10',
};
case 'system':
return {
label: 'System',
icon: '⚙️',
className: 'text-stone-400 bg-stone-500/10',
};
case 'trading':
return {
label: 'Trading',
icon: '📈',
className: 'text-yellow-400 bg-yellow-500/10',
};
case 'security':
return {
label: 'Security',
icon: '🔒',
className: 'text-red-400 bg-red-500/10',
};
}
}