Spaces:
Runtime error
Runtime error
Commit
·
028cd37
verified
·
0
Parent(s):
Initial deployment
Browse files- IMPLEMENTATION_SUMMARY.md +82 -0
- README.md +167 -0
- app.py +40 -0
- archive/ui_old/__init__.py +8 -0
- archive/ui_old/__pycache__/__init__.cpython-313.pyc +0 -0
- archive/ui_old/__pycache__/main.cpython-313.pyc +0 -0
- archive/ui_old/archive/ui_chat.py +165 -0
- archive/ui_old/archive/ui_fixed.py +974 -0
- archive/ui_old/archive/ui_gradio_render.py +414 -0
- archive/ui_old/archive/ui_handlers_fix.py +41 -0
- archive/ui_old/archive/ui_refactored_example.py +384 -0
- archive/ui_old/archive/ui_simplified.py +137 -0
- archive/ui_old/archive/ui_streaming.py +144 -0
- archive/ui_old/archive/ui_streaming_fixed.py +172 -0
- archive/ui_old/handlers/__init__.py +7 -0
- archive/ui_old/handlers/preview.py +123 -0
- archive/ui_old/main.py +769 -0
- archive/ui_old/utils/__init__.py +26 -0
- archive/ui_old/utils/display.py +351 -0
- archive/ui_old/utils/helpers.py +29 -0
- components/.DS_Store +0 -0
- components/__init__.py +1 -0
- components/__pycache__/__init__.cpython-313.pyc +0 -0
- components/__pycache__/agent.cpython-313.pyc +0 -0
- components/__pycache__/agent_streaming.cpython-313.pyc +0 -0
- components/__pycache__/mcp_client.cpython-313.pyc +0 -0
- components/__pycache__/models.cpython-313.pyc +0 -0
- components/__pycache__/session_manager.cpython-313.pyc +0 -0
- components/__pycache__/simple_agent.cpython-313.pyc +0 -0
- components/__pycache__/ui.cpython-313.pyc +0 -0
- components/__pycache__/ui_chat.cpython-313.pyc +0 -0
- components/__pycache__/ui_streaming.cpython-313.pyc +0 -0
- components/__pycache__/ui_streaming_fixed.cpython-313.pyc +0 -0
- components/__pycache__/ui_tools.cpython-313.pyc +0 -0
- components/__pycache__/ui_utils.cpython-313.pyc +0 -0
- components/agent_streaming.py +145 -0
- components/mcp_client.py +587 -0
- components/models.py +40 -0
- components/session_manager.py +114 -0
- components/simple_agent.py +553 -0
- components/ui.py +2237 -0
- components/ui_chat.py +176 -0
- components/ui_tools.py +165 -0
- components/ui_utils.py +760 -0
- data/emails.json +115 -0
- data/rules.json +30 -0
- requirements.txt +24 -0
IMPLEMENTATION_SUMMARY.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Email Rule Agent - Architecture Fix Implementation Summary
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
Successfully fixed the Email Rule Agent architecture to properly use MCP (Model Context Protocol) backend instead of local state management, and added toast notifications and rule archive/reactivate features.
|
| 5 |
+
|
| 6 |
+
## Key Changes Made
|
| 7 |
+
|
| 8 |
+
### 1. Fixed Data Loading (Phase 1)
|
| 9 |
+
- **Updated `initialize_app()`** to fetch data from MCP backend instead of loading local JSON files
|
| 10 |
+
- **Removed local data loading functions**: `load_email_data()` and `load_rules_from_json()`
|
| 11 |
+
- **Removed file path constants**: DATA_PATH, PREVIEW_DATA_PATH, RULES_DATA_PATH
|
| 12 |
+
|
| 13 |
+
### 2. Cleaned Up State Management (Phase 2)
|
| 14 |
+
- **Removed duplicate local-only functions**: `accept_rule()`, `reject_rule()`, `archive_rule()`, `reactivate_rule()`, `preview_rule()`, `preview_rule_with_mcp()`
|
| 15 |
+
- **Renamed MCP functions** for clarity:
|
| 16 |
+
- `accept_rule_with_mcp` → `handle_accept_rule`
|
| 17 |
+
- `run_rule_with_mcp` → `handle_run_rule`
|
| 18 |
+
- Kept `handle_preview_rule` as is
|
| 19 |
+
- **Simplified all handlers** by removing fallback logic
|
| 20 |
+
|
| 21 |
+
### 3. Implemented Archive/Reactivate (Phase 3)
|
| 22 |
+
- **Added backend endpoint** `/rules/{rule_id}` with PATCH method in `modal_server.py`
|
| 23 |
+
- **Added storage methods** in `storage.py`:
|
| 24 |
+
- `update_user_rule()` - Updates rule properties
|
| 25 |
+
- `get_user_rule()` - Gets a specific rule
|
| 26 |
+
- **Added MCP client methods**:
|
| 27 |
+
- `update_rule_status()` - Updates rule status
|
| 28 |
+
- `archive_rule()` - Sets status to 'rejected'
|
| 29 |
+
- `reactivate_rule()` - Sets status to 'pending'
|
| 30 |
+
- **Implemented UI handlers**: `handle_archive_rule()` and `handle_reactivate_rule()`
|
| 31 |
+
|
| 32 |
+
### 4. Added Toast Notifications (Phase 4)
|
| 33 |
+
- **Integrated Toastify.js** for modern toast notifications
|
| 34 |
+
- **Added status observer** in JavaScript to watch for status_msg changes
|
| 35 |
+
- **Color-coded toasts** based on message type (success, error, warning, info)
|
| 36 |
+
- All user actions now show toast notifications instead of hidden status messages
|
| 37 |
+
|
| 38 |
+
### 5. Enhanced Mock Provider (Phase 5)
|
| 39 |
+
- **Made mock provider session-aware** with `_mock_sessions` storage
|
| 40 |
+
- **Added support for rule status updates** via PATCH method
|
| 41 |
+
- **Session isolation** - each user gets their own data
|
| 42 |
+
- **Fixed endpoint matching** for proper request routing
|
| 43 |
+
|
| 44 |
+
## Architecture Benefits
|
| 45 |
+
|
| 46 |
+
### Before
|
| 47 |
+
- UI loaded data from local JSON files
|
| 48 |
+
- State changes only in memory
|
| 49 |
+
- No persistence
|
| 50 |
+
- All users saw same data
|
| 51 |
+
- Hidden status messages
|
| 52 |
+
|
| 53 |
+
### After
|
| 54 |
+
- All data flows through MCP backend
|
| 55 |
+
- Proper client-server architecture
|
| 56 |
+
- Session-based persistence
|
| 57 |
+
- User isolation
|
| 58 |
+
- Toast notifications for all actions
|
| 59 |
+
- Single source of truth (backend)
|
| 60 |
+
|
| 61 |
+
## Testing
|
| 62 |
+
Created and ran comprehensive tests to verify:
|
| 63 |
+
- ✅ All imports work correctly
|
| 64 |
+
- ✅ MCP client functions in local mode
|
| 65 |
+
- ✅ App can be created successfully
|
| 66 |
+
- ✅ Session isolation works
|
| 67 |
+
- ✅ Rule CRUD operations work
|
| 68 |
+
- ✅ Archive/reactivate functionality works
|
| 69 |
+
|
| 70 |
+
## Files Modified
|
| 71 |
+
1. `components/ui.py` - Major refactor for MCP integration
|
| 72 |
+
2. `components/ui_utils.py` - Removed local data loading
|
| 73 |
+
3. `components/mcp_client.py` - Added new methods and session support
|
| 74 |
+
4. `modal-backend/modal_server.py` - Added rule update endpoint
|
| 75 |
+
5. `modal-backend/storage.py` - Added update methods
|
| 76 |
+
|
| 77 |
+
## Next Steps
|
| 78 |
+
The app is now ready for deployment with proper architecture:
|
| 79 |
+
- Deploy Modal backend with `modal deploy modal-backend/modal_server.py`
|
| 80 |
+
- Deploy HF Spaces frontend
|
| 81 |
+
- Configure environment variables for Modal URL
|
| 82 |
+
- Test with real Gmail integration
|
README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Email Rule Agent - AI-Powered Email Organization Assistant
|
| 3 |
+
emoji: 📧
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
tags:
|
| 11 |
+
- agent-demo-track
|
| 12 |
+
- gradio
|
| 13 |
+
- ai-agents
|
| 14 |
+
- mcp
|
| 15 |
+
- email-management
|
| 16 |
+
license: mit
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
# 🤖 Email Rule Agent - AI-Powered Email Organization Assistant
|
| 20 |
+
|
| 21 |
+
> **Track 3: Agentic Demo Showcase** submission for the Gradio Agents & MCP Hackathon 2025
|
| 22 |
+
|
| 23 |
+
## 🎥 Demo Video
|
| 24 |
+
[Watch the demo video here](https://your-video-link.com) <!-- TODO: Add your demo video link -->
|
| 25 |
+
|
| 26 |
+
## 🌟 Overview
|
| 27 |
+
|
| 28 |
+
Email Rule Agent is an intelligent email management assistant that uses AI agents to help users organize their inbox through natural language commands. Simply describe what you want in plain English, and the AI agent will create smart rules to automatically organize your emails.
|
| 29 |
+
|
| 30 |
+
**Key Innovation**: Combines conversational AI with the Model Context Protocol (MCP) to provide a seamless, intelligent email management experience that learns from your preferences and adapts to your workflow.
|
| 31 |
+
|
| 32 |
+
## ✨ Key Features
|
| 33 |
+
|
| 34 |
+
### 🧠 AI-Powered Natural Language Understanding
|
| 35 |
+
- **Conversational Rule Creation**: Just say "Move all Uber receipts to my travel folder" or "Archive old newsletters"
|
| 36 |
+
- **Context-Aware Processing**: Understands references like "those emails" or "emails from last week"
|
| 37 |
+
- **Smart Pattern Recognition**: Analyzes your inbox to suggest organization rules
|
| 38 |
+
|
| 39 |
+
### 📋 Intelligent Rule Management
|
| 40 |
+
- **Preview Before Apply**: See exactly which emails will be affected before confirming
|
| 41 |
+
- **Confidence Scoring**: Each rule comes with a confidence level
|
| 42 |
+
- **Rule Lifecycle**: Accept, reject, archive, or reactivate rules as needed
|
| 43 |
+
- **Batch Operations**: Apply rules to multiple emails at once
|
| 44 |
+
|
| 45 |
+
### 🔄 Real-Time MCP Integration
|
| 46 |
+
- **Client-Server Architecture**: Uses MCP protocol for robust data management
|
| 47 |
+
- **Session Isolation**: Each user gets their own workspace
|
| 48 |
+
- **Persistent Storage**: Rules and preferences are saved
|
| 49 |
+
- **Mock & Real Data**: Demo mode with sample emails or connect your Gmail
|
| 50 |
+
|
| 51 |
+
### 🎨 Modern User Experience
|
| 52 |
+
- **Three-Column Layout**: Chat interface, rule management, and email preview
|
| 53 |
+
- **Real-Time Updates**: Toast notifications for all actions
|
| 54 |
+
- **Streaming Responses**: See AI thinking in real-time
|
| 55 |
+
- **Interactive Demo**: Built-in checklist guides new users
|
| 56 |
+
|
| 57 |
+
## 🚀 Try It Out
|
| 58 |
+
|
| 59 |
+
1. **Start with the Demo**: Click "Analyze my inbox" to see pattern detection in action
|
| 60 |
+
2. **Create Natural Language Rules**: Try commands like:
|
| 61 |
+
- "Archive all marketing emails older than 30 days"
|
| 62 |
+
- "Move receipts to my expenses folder"
|
| 63 |
+
- "Label all work emails as important"
|
| 64 |
+
3. **Preview and Apply**: Review which emails match before applying rules
|
| 65 |
+
4. **Manage Your Rules**: Accept, reject, or modify rules as needed
|
| 66 |
+
|
| 67 |
+
## 🏗️ Architecture
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
| 71 |
+
│ │ │ │ │ │
|
| 72 |
+
│ Gradio UI │────▶│ MCP Client │────▶│ Modal Backend │
|
| 73 |
+
│ (Frontend) │ │ (Protocol) │ │ (MCP Server) │
|
| 74 |
+
│ │ │ │ │ │
|
| 75 |
+
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
| 76 |
+
│ │
|
| 77 |
+
│ │
|
| 78 |
+
▼ ▼
|
| 79 |
+
┌─────────────────┐ ┌─────────────────┐
|
| 80 |
+
│ AI Agent │ │ Email Provider │
|
| 81 |
+
│ (CrewAI + │ │ (Gmail/Mock) │
|
| 82 |
+
│ OpenRouter) │ │ │
|
| 83 |
+
└─────────────────┘ └─────────────────┘
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### MCP Integration
|
| 87 |
+
- **MCP Client**: Handles all communication with the backend server
|
| 88 |
+
- **Standardized Endpoints**: `/emails`, `/rules`, `/auth` following MCP conventions
|
| 89 |
+
- **Session Management**: Automatic session handling for multi-user support
|
| 90 |
+
- **Error Handling**: Graceful fallbacks and user-friendly error messages
|
| 91 |
+
|
| 92 |
+
## 🛠️ Technical Stack
|
| 93 |
+
|
| 94 |
+
- **Frontend**: Gradio 4.0+ with custom components
|
| 95 |
+
- **AI Agent**: CrewAI with OpenRouter (supports multiple LLMs)
|
| 96 |
+
- **Backend**: Modal serverless platform
|
| 97 |
+
- **Protocol**: Model Context Protocol (MCP)
|
| 98 |
+
- **Languages**: Python 3.10+
|
| 99 |
+
- **Key Libraries**: pandas, httpx, python-dotenv
|
| 100 |
+
|
| 101 |
+
## 📦 Installation & Setup
|
| 102 |
+
|
| 103 |
+
### Option 1: Use This Space
|
| 104 |
+
Simply use this Hugging Face Space - it's already configured and ready to go!
|
| 105 |
+
|
| 106 |
+
### Option 2: Local Development
|
| 107 |
+
```bash
|
| 108 |
+
# Clone the repository
|
| 109 |
+
git clone https://huggingface.co/spaces/YOUR_USERNAME/email-rule-agent
|
| 110 |
+
cd email-rule-agent
|
| 111 |
+
|
| 112 |
+
# Install dependencies
|
| 113 |
+
pip install -r requirements.txt
|
| 114 |
+
|
| 115 |
+
# Set environment variables
|
| 116 |
+
export OPENROUTER_API_KEY="your-api-key"
|
| 117 |
+
export MODAL_BACKEND_URL="your-modal-url" # or use --local flag
|
| 118 |
+
|
| 119 |
+
# Run the app
|
| 120 |
+
python app.py --local # For local/mock mode
|
| 121 |
+
python app.py # For production mode
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
### Required Secrets (for HF Spaces)
|
| 125 |
+
- `OPENROUTER_API_KEY`: Your OpenRouter API key for AI capabilities
|
| 126 |
+
- `MODAL_BACKEND_URL`: Your Modal backend URL (optional, uses mock data if not set)
|
| 127 |
+
|
| 128 |
+
## 📸 Screenshots
|
| 129 |
+
|
| 130 |
+
### Main Interface
|
| 131 |
+
The three-column layout provides an intuitive workflow:
|
| 132 |
+
- **Left**: Chat with the AI agent
|
| 133 |
+
- **Center**: Manage your rules
|
| 134 |
+
- **Right**: Preview affected emails
|
| 135 |
+
|
| 136 |
+
### Natural Language Processing
|
| 137 |
+
Simply describe what you want in plain English, and the AI understands your intent.
|
| 138 |
+
|
| 139 |
+
### Rule Preview
|
| 140 |
+
See exactly which emails will be affected before applying any rule.
|
| 141 |
+
|
| 142 |
+
## 🏆 Why This Matters
|
| 143 |
+
|
| 144 |
+
Email overload is a universal problem. This project demonstrates how AI agents can:
|
| 145 |
+
- **Understand Intent**: No need to learn complex filter syntax
|
| 146 |
+
- **Learn Patterns**: Automatically identify email categories
|
| 147 |
+
- **Save Time**: Automate repetitive organization tasks
|
| 148 |
+
- **Stay in Control**: Preview and approve all actions
|
| 149 |
+
|
| 150 |
+
## 🔮 Future Enhancements
|
| 151 |
+
|
| 152 |
+
- **Multi-Provider Support**: Outlook, Yahoo Mail integration
|
| 153 |
+
- **Advanced Actions**: Smart replies, calendar integration
|
| 154 |
+
- **Team Features**: Shared rules and collaborative filtering
|
| 155 |
+
- **Mobile App**: Native mobile experience
|
| 156 |
+
|
| 157 |
+
## 👥 Team
|
| 158 |
+
|
| 159 |
+
Created with ❤️ for the Gradio Agents & MCP Hackathon 2025
|
| 160 |
+
|
| 161 |
+
## 📄 License
|
| 162 |
+
|
| 163 |
+
MIT License - feel free to use and extend!
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
**Note**: This is a hackathon project demonstrating the potential of AI agents and MCP. For production use, ensure proper security measures and API rate limiting.
|
app.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Email Rule Agent - HuggingFace Spaces App
|
| 3 |
+
Main entry point for the Gradio interface
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import argparse
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
# Load environment variables
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
# Add components to path
|
| 15 |
+
sys.path.append(os.path.dirname(__file__))
|
| 16 |
+
|
| 17 |
+
from components.ui import create_app, launch_app
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
# Simple setup - prioritize HF Spaces deployment
|
| 21 |
+
modal_url = os.getenv('MODAL_BACKEND_URL', 'local://mock')
|
| 22 |
+
|
| 23 |
+
if not os.getenv('OPENROUTER_API_KEY'):
|
| 24 |
+
print("⚠️ Warning: OPENROUTER_API_KEY not set")
|
| 25 |
+
print("The AI agent features will not work properly.")
|
| 26 |
+
|
| 27 |
+
# Create and launch the app
|
| 28 |
+
demo = create_app(modal_url)
|
| 29 |
+
|
| 30 |
+
# Check if running on HF Spaces
|
| 31 |
+
is_hf_spaces = os.getenv('SPACE_ID') is not None
|
| 32 |
+
|
| 33 |
+
# Launch with appropriate settings
|
| 34 |
+
demo.launch(
|
| 35 |
+
server_name="0.0.0.0" if is_hf_spaces else None,
|
| 36 |
+
server_port=7860 if is_hf_spaces else None
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
if __name__ == "__main__":
|
| 40 |
+
main()
|
archive/ui_old/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI components package - temporary redirect during reorganization
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
# Import from the new location in components/
|
| 6 |
+
from ..ui import create_app, launch_app
|
| 7 |
+
|
| 8 |
+
__all__ = ['create_app', 'launch_app']
|
archive/ui_old/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (356 Bytes). View file
|
|
|
archive/ui_old/__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (29 kB). View file
|
|
|
archive/ui_old/archive/ui_chat.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enhanced chat handling with tool visibility and proper streaming
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
import json
|
| 7 |
+
from typing import List, Dict, Any, Tuple
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def format_tool_message(tool_name: str, details: str = "", status: str = "running") -> str:
|
| 13 |
+
"""Format a tool call as HTML content"""
|
| 14 |
+
icons = {
|
| 15 |
+
"analyzing_emails": "📊",
|
| 16 |
+
"extracting_patterns": "🔍",
|
| 17 |
+
"creating_rules": "📋",
|
| 18 |
+
"thinking": "🤔"
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Create a styled div that looks like a tool call
|
| 22 |
+
tool_content = f"""
|
| 23 |
+
<div style="background: #e3f2fd; border: 1px solid #1976d2; border-radius: 8px; padding: 12px; margin: 8px 0;">
|
| 24 |
+
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
| 25 |
+
<span style="font-size: 20px;">{icons.get(tool_name, '🔧')}</span>
|
| 26 |
+
<span style="font-weight: 600; color: #1976d2;">{tool_name.replace('_', ' ').title()}</span>
|
| 27 |
+
<span style="margin-left: auto; color: #666; font-size: 12px;">
|
| 28 |
+
{'⏳ Running...' if status == 'running' else '✓ Complete'}
|
| 29 |
+
</span>
|
| 30 |
+
</div>
|
| 31 |
+
<div style="background: white; border-radius: 4px; padding: 8px; font-family: monospace; font-size: 12px; white-space: pre-wrap;">
|
| 32 |
+
{details or 'Processing...'}
|
| 33 |
+
</div>
|
| 34 |
+
</div>"""
|
| 35 |
+
|
| 36 |
+
return tool_content
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def process_chat_with_tools(
|
| 40 |
+
user_message: str,
|
| 41 |
+
chat_history: List[Dict[str, str]],
|
| 42 |
+
mcp_client: Any,
|
| 43 |
+
current_emails: List[Dict[str, Any]],
|
| 44 |
+
pending_rules: List[Dict[str, Any]],
|
| 45 |
+
applied_rules: List[Dict[str, Any]],
|
| 46 |
+
rule_counter: int
|
| 47 |
+
) -> Tuple:
|
| 48 |
+
"""Process chat messages with tool visibility"""
|
| 49 |
+
|
| 50 |
+
from .simple_agent import process_chat_message as process_agent_message
|
| 51 |
+
from .ui_utils import create_interactive_rule_cards
|
| 52 |
+
|
| 53 |
+
if not user_message.strip():
|
| 54 |
+
return (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 55 |
+
"Ready...", pending_rules, rule_counter)
|
| 56 |
+
|
| 57 |
+
# Check if API key is available
|
| 58 |
+
if not os.getenv('OPENROUTER_API_KEY'):
|
| 59 |
+
chat_history.append({"role": "user", "content": user_message})
|
| 60 |
+
error_msg = "⚠️ OpenRouter API key not configured. Please set OPENROUTER_API_KEY in HuggingFace Spaces secrets to enable AI features."
|
| 61 |
+
chat_history.append({"role": "assistant", "content": error_msg})
|
| 62 |
+
return (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 63 |
+
"API key missing", pending_rules, rule_counter)
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
# Add user message
|
| 67 |
+
chat_history.append({"role": "user", "content": user_message})
|
| 68 |
+
|
| 69 |
+
# Check if we should analyze emails
|
| 70 |
+
analyze_keywords = ['analyze', 'suggest', 'organize', 'rules', 'help me organize',
|
| 71 |
+
'create rules', 'inbox', 'patterns']
|
| 72 |
+
should_analyze = any(keyword in user_message.lower() for keyword in analyze_keywords)
|
| 73 |
+
|
| 74 |
+
if should_analyze and current_emails:
|
| 75 |
+
# Add tool message for email analysis
|
| 76 |
+
analysis_details = f"""Analyzing {len(current_emails)} emails...
|
| 77 |
+
|
| 78 |
+
Looking for patterns in:
|
| 79 |
+
• Sender domains and addresses
|
| 80 |
+
• Subject line keywords
|
| 81 |
+
• Email frequency by sender
|
| 82 |
+
• Common phrases and topics
|
| 83 |
+
• Time patterns
|
| 84 |
+
|
| 85 |
+
Email categories found:
|
| 86 |
+
• Newsletters: {len([e for e in current_emails if 'newsletter' in e.get('from_email', '').lower()])}
|
| 87 |
+
• Work emails: {len([e for e in current_emails if any(domain in e.get('from_email', '') for domain in ['company.com', 'work.com'])])}
|
| 88 |
+
• Personal: {len([e for e in current_emails if not any(keyword in e.get('from_email', '').lower() for keyword in ['newsletter', 'promo', 'noreply'])])}
|
| 89 |
+
"""
|
| 90 |
+
# Create a combined message with tool output
|
| 91 |
+
tool_html = format_tool_message("analyzing_emails", analysis_details, "complete")
|
| 92 |
+
# Add as assistant message with special formatting
|
| 93 |
+
chat_history.append({
|
| 94 |
+
"role": "assistant",
|
| 95 |
+
"content": tool_html
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
# Get current rule state
|
| 99 |
+
rule_state = {
|
| 100 |
+
'proposedRules': [r for r in pending_rules if r['status'] == 'pending'],
|
| 101 |
+
'activeRules': applied_rules,
|
| 102 |
+
'rejectedRules': [r for r in pending_rules if r['status'] == 'rejected']
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
# Call the agent
|
| 106 |
+
response_data = process_agent_message(
|
| 107 |
+
user_message=user_message,
|
| 108 |
+
emails=current_emails,
|
| 109 |
+
conversation_history=chat_history[:-2] if should_analyze else chat_history[:-1], # Exclude tool message
|
| 110 |
+
rule_state=rule_state
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Extract response and rules
|
| 114 |
+
response_text = response_data.get('response', '')
|
| 115 |
+
extracted_rules = response_data.get('rules', [])
|
| 116 |
+
|
| 117 |
+
# If rules were extracted, add a tool message
|
| 118 |
+
if extracted_rules:
|
| 119 |
+
rules_details = f"""Created {len(extracted_rules)} rules:
|
| 120 |
+
|
| 121 |
+
"""
|
| 122 |
+
for rule in extracted_rules:
|
| 123 |
+
rules_details += f"📋 {rule['name']}\n"
|
| 124 |
+
rules_details += f" {rule['description']}\n"
|
| 125 |
+
rules_details += f" Confidence: {rule.get('confidence', 0.8)*100:.0f}%\n\n"
|
| 126 |
+
|
| 127 |
+
tool_html = format_tool_message("creating_rules", rules_details, "complete")
|
| 128 |
+
|
| 129 |
+
# Update rules
|
| 130 |
+
updated_pending_rules = pending_rules.copy()
|
| 131 |
+
for rule in extracted_rules:
|
| 132 |
+
if not any(r.get('rule_id') == rule.get('rule_id') for r in updated_pending_rules):
|
| 133 |
+
rule['status'] = 'pending'
|
| 134 |
+
rule['id'] = rule.get('rule_id', f'rule_{rule_counter}')
|
| 135 |
+
updated_pending_rules.append(rule)
|
| 136 |
+
rule_counter += 1
|
| 137 |
+
|
| 138 |
+
# Add assistant response (clean it of hidden JSON)
|
| 139 |
+
clean_response = response_text
|
| 140 |
+
if "<!-- RULES_JSON_START" in clean_response:
|
| 141 |
+
clean_response = clean_response.split("<!-- RULES_JSON_START")[0].strip()
|
| 142 |
+
|
| 143 |
+
# If we have tool output, prepend it to the response
|
| 144 |
+
if extracted_rules and 'tool_html' in locals():
|
| 145 |
+
combined_content = tool_html + "\n\n" + clean_response
|
| 146 |
+
chat_history.append({"role": "assistant", "content": combined_content})
|
| 147 |
+
else:
|
| 148 |
+
chat_history.append({"role": "assistant", "content": clean_response})
|
| 149 |
+
|
| 150 |
+
# Status message
|
| 151 |
+
if extracted_rules:
|
| 152 |
+
status = f"✅ Found {len(extracted_rules)} new rules"
|
| 153 |
+
else:
|
| 154 |
+
status = "Processing complete"
|
| 155 |
+
|
| 156 |
+
return (chat_history, "", create_interactive_rule_cards(updated_pending_rules),
|
| 157 |
+
status, updated_pending_rules, rule_counter)
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
error_msg = f"I encountered an error: {str(e)}. Please try again."
|
| 161 |
+
chat_history.append({"role": "assistant", "content": error_msg})
|
| 162 |
+
return (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 163 |
+
f"Error: {str(e)}", pending_rules, rule_counter)
|
| 164 |
+
|
| 165 |
+
|
archive/ui_old/archive/ui_fixed.py
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI Components for Email Rule Agent - FIXED VERSION
|
| 3 |
+
Native Gradio implementation with proper event handling for @gr.render
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import logging
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import List, Dict, Any, Tuple
|
| 12 |
+
|
| 13 |
+
# Set up logging
|
| 14 |
+
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# Import UI utility functions
|
| 18 |
+
from .ui_utils import (
|
| 19 |
+
load_email_data,
|
| 20 |
+
get_folder_counts,
|
| 21 |
+
get_folder_dropdown_choices,
|
| 22 |
+
extract_folder_name,
|
| 23 |
+
format_date,
|
| 24 |
+
sort_emails,
|
| 25 |
+
create_email_html,
|
| 26 |
+
filter_emails,
|
| 27 |
+
create_preview_banner,
|
| 28 |
+
create_interactive_rule_cards
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# Data paths
|
| 32 |
+
DATA_PATH = "./data/emails.json"
|
| 33 |
+
PREVIEW_DATA_PATH = "./data/preview_emails.json"
|
| 34 |
+
RULES_DATA_PATH = "./data/rules.json"
|
| 35 |
+
|
| 36 |
+
# Import agent
|
| 37 |
+
from .simple_agent import process_chat_message as process_agent_message, extract_rules_from_output
|
| 38 |
+
|
| 39 |
+
# Import MCP client
|
| 40 |
+
from .mcp_client import MCPClient
|
| 41 |
+
|
| 42 |
+
# Import session manager
|
| 43 |
+
from .session_manager import session_manager
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def create_rule_event_handler(action: str, mcp_client: MCPClient):
|
| 47 |
+
"""Create a generic event handler for rule actions that works with @gr.render"""
|
| 48 |
+
|
| 49 |
+
def handler(evt: gr.EventData, *args):
|
| 50 |
+
"""Generic handler that extracts rule_id from the event data"""
|
| 51 |
+
# Extract rule_id from the component that triggered the event
|
| 52 |
+
try:
|
| 53 |
+
# Get the button element that was clicked
|
| 54 |
+
button_elem = evt.target
|
| 55 |
+
logger.debug(f"Event triggered by: {button_elem}")
|
| 56 |
+
|
| 57 |
+
# The rule_id should be embedded in the button's key or elem_id
|
| 58 |
+
if hasattr(button_elem, 'key') and button_elem.key:
|
| 59 |
+
# Extract rule_id from key like "rule_123_preview"
|
| 60 |
+
parts = button_elem.key.split('_')
|
| 61 |
+
if len(parts) >= 3: # rule_ID_action
|
| 62 |
+
rule_id = f"{parts[0]}_{parts[1]}"
|
| 63 |
+
else:
|
| 64 |
+
rule_id = None
|
| 65 |
+
else:
|
| 66 |
+
rule_id = None
|
| 67 |
+
|
| 68 |
+
logger.info(f"Extracted rule_id: {rule_id} for action: {action}")
|
| 69 |
+
|
| 70 |
+
if not rule_id:
|
| 71 |
+
logger.error("Could not extract rule_id from event")
|
| 72 |
+
return handle_error(action, args)
|
| 73 |
+
|
| 74 |
+
# Call the appropriate handler based on action
|
| 75 |
+
if action == 'preview':
|
| 76 |
+
return handle_preview(rule_id, mcp_client, *args)
|
| 77 |
+
elif action == 'accept':
|
| 78 |
+
return handle_accept(rule_id, mcp_client, *args)
|
| 79 |
+
elif action == 'reject':
|
| 80 |
+
return handle_reject(rule_id, *args)
|
| 81 |
+
elif action == 'cancel':
|
| 82 |
+
return handle_cancel(rule_id, *args)
|
| 83 |
+
else:
|
| 84 |
+
return handle_error(action, args)
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error(f"Error in event handler for {action}: {e}")
|
| 88 |
+
return handle_error(action, args)
|
| 89 |
+
|
| 90 |
+
return handler
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def handle_preview(rule_id: str, mcp_client: MCPClient, sort_option, search_query,
|
| 94 |
+
user_emails_state, preview_emails_state, pending_rules_state):
|
| 95 |
+
"""Handle rule preview action"""
|
| 96 |
+
logger.info(f"Preview handler called for rule: {rule_id}")
|
| 97 |
+
|
| 98 |
+
# Find the rule
|
| 99 |
+
rule = next((r for r in pending_rules_state if r["id"] == rule_id), None)
|
| 100 |
+
if not rule:
|
| 101 |
+
logger.error(f"Rule not found: {rule_id}")
|
| 102 |
+
return (gr.update(), gr.update(), pending_rules_state,
|
| 103 |
+
create_preview_banner(get_preview_rules_count(pending_rules_state)),
|
| 104 |
+
f"❌ Rule not found: {rule_id}",
|
| 105 |
+
user_emails_state, preview_emails_state)
|
| 106 |
+
|
| 107 |
+
# Update rule status
|
| 108 |
+
updated_rules = pending_rules_state.copy()
|
| 109 |
+
for r in updated_rules:
|
| 110 |
+
if r["id"] == rule_id:
|
| 111 |
+
r["status"] = "preview"
|
| 112 |
+
break
|
| 113 |
+
|
| 114 |
+
# Try MCP preview
|
| 115 |
+
try:
|
| 116 |
+
# Convert rule to MCP format
|
| 117 |
+
mcp_rule = {
|
| 118 |
+
"rule_id": rule_id,
|
| 119 |
+
"name": rule["name"],
|
| 120 |
+
"conditions": [],
|
| 121 |
+
"actions": []
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
# Parse conditions
|
| 125 |
+
for condition in rule.get("conditions", []):
|
| 126 |
+
if isinstance(condition, dict):
|
| 127 |
+
mcp_rule["conditions"].append(condition)
|
| 128 |
+
elif isinstance(condition, str):
|
| 129 |
+
parts = condition.split()
|
| 130 |
+
if len(parts) >= 3:
|
| 131 |
+
mcp_rule["conditions"].append({
|
| 132 |
+
"field": parts[0],
|
| 133 |
+
"operator": parts[1],
|
| 134 |
+
"value": " ".join(parts[2:]).strip("'")
|
| 135 |
+
})
|
| 136 |
+
|
| 137 |
+
# Parse actions
|
| 138 |
+
for action in rule.get("actions", []):
|
| 139 |
+
if isinstance(action, dict):
|
| 140 |
+
mcp_rule["actions"].append(action)
|
| 141 |
+
elif isinstance(action, str):
|
| 142 |
+
if "move" in action.lower():
|
| 143 |
+
folder = action.split("'")[-2] if "'" in action else "inbox"
|
| 144 |
+
mcp_rule["actions"].append({
|
| 145 |
+
"type": "move",
|
| 146 |
+
"parameters": {"folder": folder}
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
# Call MCP preview
|
| 150 |
+
preview_response = mcp_client.preview_rule(mcp_rule)
|
| 151 |
+
|
| 152 |
+
if preview_response.get('success', False):
|
| 153 |
+
# Update preview emails with the result
|
| 154 |
+
new_preview_emails = preview_response.get('affected_emails', preview_emails_state)
|
| 155 |
+
|
| 156 |
+
new_folder_choices = get_folder_dropdown_choices(new_preview_emails)
|
| 157 |
+
new_folder_value = new_folder_choices[0] if new_folder_choices else "Inbox (0)"
|
| 158 |
+
filtered_emails = filter_emails(new_folder_value, search_query, sort_option, new_preview_emails)
|
| 159 |
+
new_email_display = create_email_html(filtered_emails, new_folder_value)
|
| 160 |
+
|
| 161 |
+
stats = preview_response.get('statistics', {})
|
| 162 |
+
status_msg = f"👁️ Preview: {stats.get('matched_count', 0)} emails would be affected"
|
| 163 |
+
|
| 164 |
+
return (gr.update(choices=new_folder_choices, value=new_folder_value),
|
| 165 |
+
new_email_display,
|
| 166 |
+
updated_rules,
|
| 167 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 168 |
+
status_msg,
|
| 169 |
+
new_preview_emails,
|
| 170 |
+
new_preview_emails)
|
| 171 |
+
else:
|
| 172 |
+
# Fallback - just update status
|
| 173 |
+
return (gr.update(), gr.update(), updated_rules,
|
| 174 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 175 |
+
f"👁️ Previewing rule: {rule['name']}",
|
| 176 |
+
user_emails_state, preview_emails_state)
|
| 177 |
+
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(f"Error in preview: {e}")
|
| 180 |
+
return (gr.update(), gr.update(), updated_rules,
|
| 181 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 182 |
+
f"❌ Error: {str(e)}",
|
| 183 |
+
user_emails_state, preview_emails_state)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def handle_accept(rule_id: str, mcp_client: MCPClient, current_folder, sort_option, search_query,
|
| 187 |
+
user_emails_state, preview_emails_state, pending_rules_state, applied_rules_state):
|
| 188 |
+
"""Handle rule acceptance action"""
|
| 189 |
+
logger.info(f"Accept handler called for rule: {rule_id}")
|
| 190 |
+
|
| 191 |
+
# Find the rule
|
| 192 |
+
rule = next((r for r in pending_rules_state if r["id"] == rule_id), None)
|
| 193 |
+
if not rule:
|
| 194 |
+
logger.error(f"Rule not found: {rule_id}")
|
| 195 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, user_emails_state)
|
| 196 |
+
return (pending_rules_state,
|
| 197 |
+
create_preview_banner(get_preview_rules_count(pending_rules_state)),
|
| 198 |
+
f"❌ Rule not found: {rule_id}",
|
| 199 |
+
create_email_html(filtered_emails, current_folder),
|
| 200 |
+
gr.update(),
|
| 201 |
+
user_emails_state,
|
| 202 |
+
preview_emails_state,
|
| 203 |
+
applied_rules_state)
|
| 204 |
+
|
| 205 |
+
# Try MCP apply
|
| 206 |
+
try:
|
| 207 |
+
# Convert rule to MCP format (same as preview)
|
| 208 |
+
mcp_rule = {
|
| 209 |
+
"rule_id": rule_id,
|
| 210 |
+
"name": rule["name"],
|
| 211 |
+
"conditions": [],
|
| 212 |
+
"actions": []
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
# Parse conditions and actions (same as preview)
|
| 216 |
+
for condition in rule.get("conditions", []):
|
| 217 |
+
if isinstance(condition, dict):
|
| 218 |
+
mcp_rule["conditions"].append(condition)
|
| 219 |
+
elif isinstance(condition, str):
|
| 220 |
+
parts = condition.split()
|
| 221 |
+
if len(parts) >= 3:
|
| 222 |
+
mcp_rule["conditions"].append({
|
| 223 |
+
"field": parts[0],
|
| 224 |
+
"operator": parts[1],
|
| 225 |
+
"value": " ".join(parts[2:]).strip("'")
|
| 226 |
+
})
|
| 227 |
+
|
| 228 |
+
for action in rule.get("actions", []):
|
| 229 |
+
if isinstance(action, dict):
|
| 230 |
+
mcp_rule["actions"].append(action)
|
| 231 |
+
elif isinstance(action, str):
|
| 232 |
+
if "move" in action.lower():
|
| 233 |
+
folder = action.split("'")[-2] if "'" in action else "inbox"
|
| 234 |
+
mcp_rule["actions"].append({
|
| 235 |
+
"type": "move",
|
| 236 |
+
"parameters": {"folder": folder}
|
| 237 |
+
})
|
| 238 |
+
|
| 239 |
+
# Call MCP apply
|
| 240 |
+
apply_response = mcp_client.apply_rule(mcp_rule)
|
| 241 |
+
|
| 242 |
+
if apply_response.get('success', False):
|
| 243 |
+
rule["status"] = "accepted"
|
| 244 |
+
|
| 245 |
+
# Update emails if provided
|
| 246 |
+
new_emails = user_emails_state
|
| 247 |
+
if 'updated_emails' in apply_response:
|
| 248 |
+
new_emails = apply_response['updated_emails']
|
| 249 |
+
|
| 250 |
+
# Update rules lists
|
| 251 |
+
updated_pending = [r for r in pending_rules_state if r["id"] != rule_id]
|
| 252 |
+
updated_applied = applied_rules_state + [rule.copy()]
|
| 253 |
+
|
| 254 |
+
# Exit preview mode if we were in it
|
| 255 |
+
for r in updated_pending:
|
| 256 |
+
if r.get("status") == "preview":
|
| 257 |
+
r["status"] = "pending"
|
| 258 |
+
|
| 259 |
+
stats = apply_response.get('statistics', {})
|
| 260 |
+
status_msg = f"✅ Rule applied: {stats.get('processed_count', 0)} emails processed"
|
| 261 |
+
|
| 262 |
+
# Update email display with new data
|
| 263 |
+
new_folder_choices = get_folder_dropdown_choices(new_emails)
|
| 264 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, new_emails)
|
| 265 |
+
|
| 266 |
+
return (updated_pending,
|
| 267 |
+
create_preview_banner(get_preview_rules_count(updated_pending)),
|
| 268 |
+
status_msg,
|
| 269 |
+
create_email_html(filtered_emails, current_folder),
|
| 270 |
+
gr.update(choices=new_folder_choices),
|
| 271 |
+
new_emails,
|
| 272 |
+
new_emails,
|
| 273 |
+
updated_applied)
|
| 274 |
+
else:
|
| 275 |
+
# Fallback - just update status locally
|
| 276 |
+
rule["status"] = "accepted"
|
| 277 |
+
updated_pending = [r for r in pending_rules_state if r["id"] != rule_id]
|
| 278 |
+
updated_applied = applied_rules_state + [rule.copy()]
|
| 279 |
+
|
| 280 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, user_emails_state)
|
| 281 |
+
return (updated_pending,
|
| 282 |
+
create_preview_banner(get_preview_rules_count(updated_pending)),
|
| 283 |
+
f"✅ Rule accepted: {rule['name']}",
|
| 284 |
+
create_email_html(filtered_emails, current_folder),
|
| 285 |
+
gr.update(),
|
| 286 |
+
user_emails_state,
|
| 287 |
+
preview_emails_state,
|
| 288 |
+
updated_applied)
|
| 289 |
+
|
| 290 |
+
except Exception as e:
|
| 291 |
+
logger.error(f"Error in accept: {e}")
|
| 292 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, user_emails_state)
|
| 293 |
+
return (pending_rules_state,
|
| 294 |
+
create_preview_banner(get_preview_rules_count(pending_rules_state)),
|
| 295 |
+
f"❌ Error: {str(e)}",
|
| 296 |
+
create_email_html(filtered_emails, current_folder),
|
| 297 |
+
gr.update(),
|
| 298 |
+
user_emails_state,
|
| 299 |
+
preview_emails_state,
|
| 300 |
+
applied_rules_state)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def handle_reject(rule_id: str, pending_rules_state):
|
| 304 |
+
"""Handle rule rejection action"""
|
| 305 |
+
logger.info(f"Reject handler called for rule: {rule_id}")
|
| 306 |
+
|
| 307 |
+
updated_rules = pending_rules_state.copy()
|
| 308 |
+
for rule in updated_rules:
|
| 309 |
+
if rule["id"] == rule_id:
|
| 310 |
+
rule["status"] = "rejected"
|
| 311 |
+
logger.info(f"Rule rejected: {rule['name']} (ID: {rule_id})")
|
| 312 |
+
return (updated_rules,
|
| 313 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 314 |
+
f"❌ Rule rejected: {rule['name']}")
|
| 315 |
+
|
| 316 |
+
return (pending_rules_state,
|
| 317 |
+
create_preview_banner(get_preview_rules_count(pending_rules_state)),
|
| 318 |
+
f"❌ Rule not found: {rule_id}")
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def handle_cancel(rule_id: str, sort_option, search_query, user_emails_state, pending_rules_state):
|
| 322 |
+
"""Handle cancel preview action"""
|
| 323 |
+
logger.info(f"Cancel handler called for rule: {rule_id}")
|
| 324 |
+
|
| 325 |
+
updated_rules = pending_rules_state.copy()
|
| 326 |
+
for rule in updated_rules:
|
| 327 |
+
if rule.get("status") == "preview":
|
| 328 |
+
rule["status"] = "pending"
|
| 329 |
+
|
| 330 |
+
new_folder_choices = get_folder_dropdown_choices(user_emails_state)
|
| 331 |
+
new_folder_value = new_folder_choices[0] if new_folder_choices else "Inbox (0)"
|
| 332 |
+
filtered_emails = filter_emails(new_folder_value, search_query, sort_option, user_emails_state)
|
| 333 |
+
new_email_display = create_email_html(filtered_emails, new_folder_value)
|
| 334 |
+
|
| 335 |
+
return (gr.update(choices=new_folder_choices, value=new_folder_value),
|
| 336 |
+
new_email_display,
|
| 337 |
+
updated_rules,
|
| 338 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 339 |
+
"🔄 Exited preview mode",
|
| 340 |
+
user_emails_state)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def handle_error(action: str, args):
|
| 344 |
+
"""Handle errors in event handlers"""
|
| 345 |
+
logger.error(f"Error handling {action} - could not determine rule_id")
|
| 346 |
+
# Return appropriate number of outputs based on action
|
| 347 |
+
if action == 'preview':
|
| 348 |
+
return (gr.update(), gr.update(), args[4], # pending_rules
|
| 349 |
+
create_preview_banner(0),
|
| 350 |
+
"❌ Error: Could not process action",
|
| 351 |
+
args[2], args[3]) # user_emails, preview_emails
|
| 352 |
+
elif action == 'accept':
|
| 353 |
+
return (args[5], # pending_rules
|
| 354 |
+
create_preview_banner(0),
|
| 355 |
+
"❌ Error: Could not process action",
|
| 356 |
+
gr.update(), gr.update(),
|
| 357 |
+
args[3], args[4], args[6]) # emails states and applied_rules
|
| 358 |
+
elif action == 'reject':
|
| 359 |
+
return (args[0], # pending_rules
|
| 360 |
+
create_preview_banner(0),
|
| 361 |
+
"❌ Error: Could not process action")
|
| 362 |
+
else:
|
| 363 |
+
return (gr.update(), gr.update(), args[3], # pending_rules
|
| 364 |
+
create_preview_banner(0),
|
| 365 |
+
"❌ Error: Could not process action",
|
| 366 |
+
args[2]) # user_emails
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def get_preview_rules_count(rules_list=None):
|
| 370 |
+
"""Get the number of rules currently in preview status"""
|
| 371 |
+
if rules_list is None:
|
| 372 |
+
return 0
|
| 373 |
+
return len([rule for rule in rules_list if rule.get("status") == "preview"])
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
# Add other helper functions from original ui.py
|
| 377 |
+
def format_conditions(conditions: List) -> str:
|
| 378 |
+
"""Format conditions for display"""
|
| 379 |
+
if not conditions:
|
| 380 |
+
return "• No conditions specified"
|
| 381 |
+
|
| 382 |
+
formatted = []
|
| 383 |
+
for condition in conditions:
|
| 384 |
+
if isinstance(condition, dict):
|
| 385 |
+
field = condition.get('field', 'field')
|
| 386 |
+
operator = condition.get('operator', 'contains')
|
| 387 |
+
value = condition.get('value', '')
|
| 388 |
+
formatted.append(f"• {field} {operator} '{value}'")
|
| 389 |
+
else:
|
| 390 |
+
formatted.append(f"• {condition}")
|
| 391 |
+
|
| 392 |
+
return "\n".join(formatted)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def format_actions(actions: List) -> str:
|
| 396 |
+
"""Format actions for display"""
|
| 397 |
+
if not actions:
|
| 398 |
+
return "• No actions specified"
|
| 399 |
+
|
| 400 |
+
formatted = []
|
| 401 |
+
for action in actions:
|
| 402 |
+
if isinstance(action, dict):
|
| 403 |
+
action_type = action.get('type', 'action')
|
| 404 |
+
if action_type == 'move':
|
| 405 |
+
folder = action.get('parameters', {}).get('folder', 'folder')
|
| 406 |
+
formatted.append(f"• Move to '{folder}'")
|
| 407 |
+
elif action_type == 'label':
|
| 408 |
+
label = action.get('parameters', {}).get('label', 'label')
|
| 409 |
+
formatted.append(f"• Add label '{label}'")
|
| 410 |
+
else:
|
| 411 |
+
formatted.append(f"• {action_type}")
|
| 412 |
+
else:
|
| 413 |
+
formatted.append(f"• {action}")
|
| 414 |
+
|
| 415 |
+
return "\n".join(formatted)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
# CSS for native components
|
| 419 |
+
NATIVE_CSS = """
|
| 420 |
+
.rule-card {
|
| 421 |
+
margin-bottom: 12px;
|
| 422 |
+
padding: 16px;
|
| 423 |
+
border-radius: 8px;
|
| 424 |
+
border: 1px solid #e1e5e9;
|
| 425 |
+
background: white;
|
| 426 |
+
transition: all 0.2s ease;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.rule-card:hover {
|
| 430 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.rule-preview {
|
| 434 |
+
background: #fff3cd;
|
| 435 |
+
border-color: #fd7e14;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.rule-accepted {
|
| 439 |
+
background: #d4edda;
|
| 440 |
+
border-color: #c3e6cb;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.rule-description {
|
| 444 |
+
color: #6c757d;
|
| 445 |
+
font-size: 14px;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.rule-conditions, .rule-actions {
|
| 449 |
+
font-size: 13px;
|
| 450 |
+
line-height: 1.6;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
.status-pending {
|
| 454 |
+
color: #6c757d;
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
.status-preview {
|
| 458 |
+
color: #fd7e14;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.status-accepted {
|
| 462 |
+
color: #28a745;
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
#rule-cards-container {
|
| 466 |
+
max-height: 500px;
|
| 467 |
+
overflow-y: auto;
|
| 468 |
+
padding-right: 8px;
|
| 469 |
+
}
|
| 470 |
+
"""
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def create_app(modal_url: str) -> gr.Blocks:
|
| 474 |
+
"""Create the main Gradio app with fixed event handling"""
|
| 475 |
+
|
| 476 |
+
# Initialize MCP client
|
| 477 |
+
mcp_client = MCPClient(modal_url)
|
| 478 |
+
|
| 479 |
+
# Load initial email data
|
| 480 |
+
initial_emails, _ = load_email_data(DATA_PATH)
|
| 481 |
+
|
| 482 |
+
# Create the Gradio interface with custom CSS
|
| 483 |
+
css = """
|
| 484 |
+
#rule-cards-container {
|
| 485 |
+
max-height: 500px;
|
| 486 |
+
overflow-y: auto;
|
| 487 |
+
padding-right: 8px;
|
| 488 |
+
}
|
| 489 |
+
""" + NATIVE_CSS
|
| 490 |
+
|
| 491 |
+
with gr.Blocks(title="Email Rule Agent", theme=gr.themes.Soft(), css=css) as demo:
|
| 492 |
+
# Initialize session state using Gradio State objects
|
| 493 |
+
session_id = gr.State()
|
| 494 |
+
|
| 495 |
+
# Per-user state objects (not shared between users)
|
| 496 |
+
user_emails = gr.State(initial_emails)
|
| 497 |
+
preview_emails = gr.State(initial_emails)
|
| 498 |
+
pending_rules = gr.State([])
|
| 499 |
+
applied_rules = gr.State([])
|
| 500 |
+
rule_counter = gr.State(0)
|
| 501 |
+
|
| 502 |
+
logger.info("UI initialized with state objects")
|
| 503 |
+
|
| 504 |
+
# Store Modal URL and MCP client in state
|
| 505 |
+
modal_state = gr.State(modal_url)
|
| 506 |
+
mcp_state = gr.State(mcp_client)
|
| 507 |
+
|
| 508 |
+
# Create event handlers outside render function
|
| 509 |
+
preview_handler = create_rule_event_handler('preview', mcp_client)
|
| 510 |
+
accept_handler = create_rule_event_handler('accept', mcp_client)
|
| 511 |
+
reject_handler = create_rule_event_handler('reject', mcp_client)
|
| 512 |
+
cancel_handler = create_rule_event_handler('cancel', mcp_client)
|
| 513 |
+
|
| 514 |
+
gr.Markdown("# 📧 Email Rule Agent")
|
| 515 |
+
gr.Markdown("*Intelligent email management powered by AI*")
|
| 516 |
+
|
| 517 |
+
# Add login choice
|
| 518 |
+
with gr.Row(visible=True) as login_row:
|
| 519 |
+
with gr.Column():
|
| 520 |
+
gr.Markdown("## Welcome! Choose how to get started:")
|
| 521 |
+
with gr.Row():
|
| 522 |
+
demo_btn = gr.Button("🎮 Try with Demo Data", variant="primary", scale=1)
|
| 523 |
+
gmail_btn = gr.Button("📧 Login with Gmail", variant="secondary", scale=1)
|
| 524 |
+
|
| 525 |
+
# Main interface (initially hidden)
|
| 526 |
+
with gr.Row(visible=False) as main_interface:
|
| 527 |
+
# Left side - Chat & Rules Interface (combined)
|
| 528 |
+
with gr.Column(scale=5):
|
| 529 |
+
# Chat Section
|
| 530 |
+
gr.Markdown("### 💬 Chat with AI Assistant")
|
| 531 |
+
chatbot = gr.Chatbot(
|
| 532 |
+
value=[],
|
| 533 |
+
type="messages",
|
| 534 |
+
height=300,
|
| 535 |
+
placeholder="Ask me to analyze your emails or create organization rules!"
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
with gr.Row():
|
| 539 |
+
msg_input = gr.Textbox(
|
| 540 |
+
placeholder="Type your message here... (e.g., 'Help me organize my newsletters')",
|
| 541 |
+
container=False,
|
| 542 |
+
scale=10
|
| 543 |
+
)
|
| 544 |
+
send_btn = gr.Button("Send", variant="primary", scale=1)
|
| 545 |
+
|
| 546 |
+
# Quick action buttons
|
| 547 |
+
with gr.Row():
|
| 548 |
+
analyze_btn = gr.Button("📊 Analyze Inbox", scale=1, size="sm")
|
| 549 |
+
suggest_btn = gr.Button("💡 Suggest Rules", scale=1, size="sm")
|
| 550 |
+
|
| 551 |
+
# Rules Section
|
| 552 |
+
gr.Markdown("### 📋 Proposed Rules")
|
| 553 |
+
|
| 554 |
+
# Status message
|
| 555 |
+
status_msg = gr.Textbox(
|
| 556 |
+
label="Status",
|
| 557 |
+
placeholder="Ready...",
|
| 558 |
+
interactive=False,
|
| 559 |
+
elem_id="status-msg"
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
# Container for the dynamic rule cards
|
| 563 |
+
with gr.Column(elem_id="rule-cards-container"):
|
| 564 |
+
# Set up the @gr.render function for rule cards
|
| 565 |
+
@gr.render(inputs=[pending_rules, applied_rules])
|
| 566 |
+
def render_rule_cards(pending_rules_list, applied_rules_list):
|
| 567 |
+
"""Render rule cards dynamically based on current rules"""
|
| 568 |
+
logger.debug(f"=== RENDER CALLED ===\nPending rules: {len(pending_rules_list) if pending_rules_list else 0}")
|
| 569 |
+
|
| 570 |
+
# If no rules, show empty state
|
| 571 |
+
if not pending_rules_list or len([r for r in pending_rules_list if r.get('status') != 'rejected']) == 0:
|
| 572 |
+
logger.debug("No pending rules to display")
|
| 573 |
+
gr.Markdown(
|
| 574 |
+
"""<div style='text-align: center; padding: 40px; color: #6c757d;'>
|
| 575 |
+
<div style='font-size: 48px; margin-bottom: 10px;'>📋</div>
|
| 576 |
+
<div style='font-size: 18px; font-weight: 500;'>No pending rules</div>
|
| 577 |
+
<div style='font-size: 14px; margin-top: 8px;'>Ask me to analyze your inbox to get started!</div>
|
| 578 |
+
</div>"""
|
| 579 |
+
)
|
| 580 |
+
return
|
| 581 |
+
|
| 582 |
+
# Count active rules
|
| 583 |
+
active_count = len([r for r in pending_rules_list if r.get('status') != 'rejected'])
|
| 584 |
+
logger.debug(f"Active rules count: {active_count}")
|
| 585 |
+
|
| 586 |
+
# Header with count
|
| 587 |
+
gr.Markdown(f"**{active_count} rules** ready for review")
|
| 588 |
+
|
| 589 |
+
# Render each rule
|
| 590 |
+
for rule in pending_rules_list:
|
| 591 |
+
if rule.get('status') == 'rejected':
|
| 592 |
+
continue
|
| 593 |
+
|
| 594 |
+
logger.debug(f"Rendering rule: {rule.get('id')} - {rule.get('name')}")
|
| 595 |
+
|
| 596 |
+
# Create a unique key for this rule
|
| 597 |
+
rule_key = f"rule_{rule['id']}"
|
| 598 |
+
|
| 599 |
+
# Determine styling based on status
|
| 600 |
+
if rule['status'] == 'accepted':
|
| 601 |
+
elem_classes = ["rule-card", "rule-accepted"]
|
| 602 |
+
elif rule['status'] == 'preview':
|
| 603 |
+
elem_classes = ["rule-card", "rule-preview"]
|
| 604 |
+
else:
|
| 605 |
+
elem_classes = ["rule-card", "rule-pending"]
|
| 606 |
+
|
| 607 |
+
with gr.Group(key=rule_key, elem_classes=elem_classes):
|
| 608 |
+
with gr.Row():
|
| 609 |
+
# Left side - Rule content
|
| 610 |
+
with gr.Column(scale=3):
|
| 611 |
+
gr.Markdown(f"**{rule.get('name', 'Unnamed Rule')}**")
|
| 612 |
+
gr.Markdown(f"*{rule.get('description', 'No description')}*", elem_classes=["rule-description"])
|
| 613 |
+
|
| 614 |
+
# Conditions section
|
| 615 |
+
with gr.Row():
|
| 616 |
+
with gr.Column():
|
| 617 |
+
gr.Markdown("**🎯 Conditions:**")
|
| 618 |
+
gr.Markdown(format_conditions(rule.get('conditions', [])), elem_classes=["rule-conditions"])
|
| 619 |
+
|
| 620 |
+
# Actions section
|
| 621 |
+
with gr.Row():
|
| 622 |
+
with gr.Column():
|
| 623 |
+
gr.Markdown("**⚡ Actions:**")
|
| 624 |
+
gr.Markdown(format_actions(rule.get('actions', [])), elem_classes=["rule-actions"])
|
| 625 |
+
|
| 626 |
+
# Right side - Status and buttons
|
| 627 |
+
with gr.Column(scale=1):
|
| 628 |
+
# Status indicator
|
| 629 |
+
if rule['status'] == 'pending':
|
| 630 |
+
gr.Markdown("⏳ **Pending Review**", elem_classes=["status-pending"])
|
| 631 |
+
elif rule['status'] == 'preview':
|
| 632 |
+
gr.Markdown("👁️ **Preview Mode**", elem_classes=["status-preview"])
|
| 633 |
+
elif rule['status'] == 'accepted':
|
| 634 |
+
gr.Markdown("✅ **Accepted**", elem_classes=["status-accepted"])
|
| 635 |
+
|
| 636 |
+
# Action buttons based on status
|
| 637 |
+
if rule['status'] == 'pending':
|
| 638 |
+
with gr.Row():
|
| 639 |
+
preview_btn = gr.Button(
|
| 640 |
+
"👁️ Preview",
|
| 641 |
+
size="sm",
|
| 642 |
+
variant="secondary",
|
| 643 |
+
key=f"{rule['id']}_preview"
|
| 644 |
+
)
|
| 645 |
+
accept_btn = gr.Button(
|
| 646 |
+
"✅ Accept",
|
| 647 |
+
size="sm",
|
| 648 |
+
variant="primary",
|
| 649 |
+
key=f"{rule['id']}_accept"
|
| 650 |
+
)
|
| 651 |
+
reject_btn = gr.Button(
|
| 652 |
+
"❌ Reject",
|
| 653 |
+
size="sm",
|
| 654 |
+
variant="stop",
|
| 655 |
+
key=f"{rule['id']}_reject"
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
# Use the pre-created handlers
|
| 659 |
+
preview_btn.click(
|
| 660 |
+
fn=preview_handler,
|
| 661 |
+
inputs=[
|
| 662 |
+
sort_dropdown, search_box, user_emails,
|
| 663 |
+
preview_emails, pending_rules
|
| 664 |
+
],
|
| 665 |
+
outputs=[
|
| 666 |
+
folder_dropdown, email_display, pending_rules,
|
| 667 |
+
preview_banner, status_msg, user_emails,
|
| 668 |
+
preview_emails
|
| 669 |
+
]
|
| 670 |
+
)
|
| 671 |
+
|
| 672 |
+
accept_btn.click(
|
| 673 |
+
fn=accept_handler,
|
| 674 |
+
inputs=[
|
| 675 |
+
folder_dropdown, sort_dropdown, search_box,
|
| 676 |
+
user_emails, preview_emails,
|
| 677 |
+
pending_rules, applied_rules
|
| 678 |
+
],
|
| 679 |
+
outputs=[
|
| 680 |
+
pending_rules, preview_banner, status_msg,
|
| 681 |
+
email_display, folder_dropdown, user_emails,
|
| 682 |
+
preview_emails, applied_rules
|
| 683 |
+
]
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
reject_btn.click(
|
| 687 |
+
fn=reject_handler,
|
| 688 |
+
inputs=[pending_rules],
|
| 689 |
+
outputs=[pending_rules, preview_banner, status_msg]
|
| 690 |
+
)
|
| 691 |
+
|
| 692 |
+
elif rule['status'] == 'preview':
|
| 693 |
+
with gr.Row():
|
| 694 |
+
accept_btn = gr.Button(
|
| 695 |
+
"✅ Apply Rule",
|
| 696 |
+
size="sm",
|
| 697 |
+
variant="primary",
|
| 698 |
+
key=f"{rule['id']}_apply"
|
| 699 |
+
)
|
| 700 |
+
cancel_btn = gr.Button(
|
| 701 |
+
"Cancel Preview",
|
| 702 |
+
size="sm",
|
| 703 |
+
variant="secondary",
|
| 704 |
+
key=f"{rule['id']}_cancel"
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
accept_btn.click(
|
| 708 |
+
fn=accept_handler,
|
| 709 |
+
inputs=[
|
| 710 |
+
folder_dropdown, sort_dropdown, search_box,
|
| 711 |
+
user_emails, preview_emails,
|
| 712 |
+
pending_rules, applied_rules
|
| 713 |
+
],
|
| 714 |
+
outputs=[
|
| 715 |
+
pending_rules, preview_banner, status_msg,
|
| 716 |
+
email_display, folder_dropdown, user_emails,
|
| 717 |
+
preview_emails, applied_rules
|
| 718 |
+
]
|
| 719 |
+
)
|
| 720 |
+
|
| 721 |
+
cancel_btn.click(
|
| 722 |
+
fn=cancel_handler,
|
| 723 |
+
inputs=[
|
| 724 |
+
sort_dropdown, search_box,
|
| 725 |
+
user_emails, pending_rules
|
| 726 |
+
],
|
| 727 |
+
outputs=[
|
| 728 |
+
folder_dropdown, email_display, pending_rules,
|
| 729 |
+
preview_banner, status_msg, user_emails
|
| 730 |
+
]
|
| 731 |
+
)
|
| 732 |
+
|
| 733 |
+
# Right side - Email Display
|
| 734 |
+
with gr.Column(scale=6):
|
| 735 |
+
# Preview banner
|
| 736 |
+
preview_banner = gr.HTML(create_preview_banner(0))
|
| 737 |
+
|
| 738 |
+
# Email controls
|
| 739 |
+
with gr.Row():
|
| 740 |
+
folder_dropdown = gr.Dropdown(
|
| 741 |
+
choices=get_folder_dropdown_choices(initial_emails),
|
| 742 |
+
value=get_folder_dropdown_choices(initial_emails)[0] if get_folder_dropdown_choices(initial_emails) else "Inbox (0)",
|
| 743 |
+
container=False,
|
| 744 |
+
scale=2,
|
| 745 |
+
allow_custom_value=True
|
| 746 |
+
)
|
| 747 |
+
search_box = gr.Textbox(
|
| 748 |
+
placeholder="Search emails...",
|
| 749 |
+
container=False,
|
| 750 |
+
scale=3
|
| 751 |
+
)
|
| 752 |
+
sort_dropdown = gr.Dropdown(
|
| 753 |
+
choices=["Newest First", "Oldest First"],
|
| 754 |
+
value="Newest First",
|
| 755 |
+
container=False,
|
| 756 |
+
scale=1
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
# Email list
|
| 760 |
+
email_display = gr.HTML(
|
| 761 |
+
value=create_email_html(initial_emails)
|
| 762 |
+
)
|
| 763 |
+
|
| 764 |
+
# Rest of the app setup (chat handlers, event handlers, etc.)
|
| 765 |
+
# Copy from original ui.py starting from line 922...
|
| 766 |
+
|
| 767 |
+
# Event handlers
|
| 768 |
+
def start_demo_mode():
|
| 769 |
+
mcp_client.set_mode('mock')
|
| 770 |
+
return gr.update(visible=False), gr.update(visible=True)
|
| 771 |
+
|
| 772 |
+
def start_gmail_mode():
|
| 773 |
+
auth_url = mcp_client.get_gmail_auth_url()
|
| 774 |
+
if auth_url:
|
| 775 |
+
print(f"Gmail auth URL: {auth_url}")
|
| 776 |
+
return gr.update(visible=False), gr.update(visible=True)
|
| 777 |
+
|
| 778 |
+
demo_btn.click(
|
| 779 |
+
start_demo_mode,
|
| 780 |
+
outputs=[login_row, main_interface]
|
| 781 |
+
)
|
| 782 |
+
|
| 783 |
+
gmail_btn.click(
|
| 784 |
+
start_gmail_mode,
|
| 785 |
+
outputs=[login_row, main_interface]
|
| 786 |
+
)
|
| 787 |
+
|
| 788 |
+
# Chat functions
|
| 789 |
+
def user(user_message, history):
|
| 790 |
+
"""Handle user message submission"""
|
| 791 |
+
if not user_message:
|
| 792 |
+
return "", history
|
| 793 |
+
history.append({"role": "user", "content": user_message})
|
| 794 |
+
return "", history
|
| 795 |
+
|
| 796 |
+
def bot(history, session_id, emails_state, pending_rules_state, applied_rules_state):
|
| 797 |
+
"""Bot response with character-by-character streaming"""
|
| 798 |
+
import time
|
| 799 |
+
|
| 800 |
+
if not history or len(history) == 0:
|
| 801 |
+
return history, emails_state, pending_rules_state
|
| 802 |
+
|
| 803 |
+
# Get the last user message
|
| 804 |
+
user_message = history[-1]["content"]
|
| 805 |
+
|
| 806 |
+
# Add empty assistant message for streaming
|
| 807 |
+
history.append({"role": "assistant", "content": ""})
|
| 808 |
+
|
| 809 |
+
# Show typing indicator
|
| 810 |
+
history[-1]["content"] = "..."
|
| 811 |
+
yield history, emails_state, pending_rules_state
|
| 812 |
+
|
| 813 |
+
# Clear typing indicator
|
| 814 |
+
history[-1]["content"] = ""
|
| 815 |
+
|
| 816 |
+
try:
|
| 817 |
+
# Get current rule state
|
| 818 |
+
rule_state = {
|
| 819 |
+
'proposedRules': [r for r in pending_rules_state if r.get('status') == 'pending'],
|
| 820 |
+
'activeRules': applied_rules_state,
|
| 821 |
+
'rejectedRules': [r for r in pending_rules_state if r.get('status') == 'rejected']
|
| 822 |
+
}
|
| 823 |
+
|
| 824 |
+
# Check if user is asking about emails/rules
|
| 825 |
+
is_analyzing = any(keyword in user_message.lower() for keyword in
|
| 826 |
+
['analyze', 'suggest', 'organize', 'rules', 'inbox'])
|
| 827 |
+
|
| 828 |
+
# Process message through agent with callback
|
| 829 |
+
def progress_callback(status: str):
|
| 830 |
+
"""Show progress in chat"""
|
| 831 |
+
if is_analyzing:
|
| 832 |
+
history[-1]["content"] = f"📊 {status}..."
|
| 833 |
+
return history
|
| 834 |
+
|
| 835 |
+
logger.debug(f"Processing message through agent: {user_message[:50]}...")
|
| 836 |
+
response_data = process_agent_message(
|
| 837 |
+
user_message=user_message,
|
| 838 |
+
emails=emails_state,
|
| 839 |
+
conversation_history=history[:-2], # Exclude user message and empty assistant
|
| 840 |
+
rule_state=rule_state,
|
| 841 |
+
callback=progress_callback if is_analyzing else None
|
| 842 |
+
)
|
| 843 |
+
|
| 844 |
+
# Extract response text and rules
|
| 845 |
+
response_text = response_data.get('response', "I couldn't process that request.")
|
| 846 |
+
extracted_rules = response_data.get('rules', [])
|
| 847 |
+
logger.debug(f"Agent response received. Rules extracted: {len(extracted_rules)}")
|
| 848 |
+
|
| 849 |
+
# Add new rules to pending BEFORE streaming
|
| 850 |
+
updated_pending = pending_rules_state.copy()
|
| 851 |
+
logger.debug(f"Current pending rules before update: {len(updated_pending)}")
|
| 852 |
+
logger.debug(f"Extracted rules from agent: {len(extracted_rules)}")
|
| 853 |
+
|
| 854 |
+
for rule in extracted_rules:
|
| 855 |
+
if not any(r.get('rule_id') == rule.get('rule_id') for r in updated_pending):
|
| 856 |
+
rule['status'] = 'pending'
|
| 857 |
+
rule['id'] = rule.get('rule_id', f"rule_{len(updated_pending)}")
|
| 858 |
+
updated_pending.append(rule)
|
| 859 |
+
logger.info(f"Added new rule: {rule['id']} - {rule.get('name')}")
|
| 860 |
+
|
| 861 |
+
logger.debug(f"Updated pending rules count: {len(updated_pending)}")
|
| 862 |
+
|
| 863 |
+
# Clear content for streaming
|
| 864 |
+
history[-1]["content"] = ""
|
| 865 |
+
|
| 866 |
+
# Stream the response character by character
|
| 867 |
+
for character in response_text:
|
| 868 |
+
history[-1]["content"] += character
|
| 869 |
+
time.sleep(0.01) # Small delay for streaming effect
|
| 870 |
+
yield history, emails_state, updated_pending # Use updated_pending here
|
| 871 |
+
|
| 872 |
+
logger.debug(f"Streaming complete, final state has {len(updated_pending)} rules")
|
| 873 |
+
# Final yield with updated state
|
| 874 |
+
yield history, emails_state, updated_pending
|
| 875 |
+
|
| 876 |
+
except Exception as e:
|
| 877 |
+
error_msg = f"❌ I encountered an error: {str(e)}. Please try again."
|
| 878 |
+
history[-1]["content"] = ""
|
| 879 |
+
for character in error_msg:
|
| 880 |
+
history[-1]["content"] += character
|
| 881 |
+
time.sleep(0.02)
|
| 882 |
+
yield history, emails_state, pending_rules_state
|
| 883 |
+
return history, emails_state, pending_rules_state
|
| 884 |
+
|
| 885 |
+
# Connect the chat handlers with proper chaining
|
| 886 |
+
msg_input.submit(user, [msg_input, chatbot], [msg_input, chatbot], queue=False).then(
|
| 887 |
+
bot, [chatbot, session_id, user_emails, pending_rules, applied_rules], [chatbot, user_emails, pending_rules]
|
| 888 |
+
)
|
| 889 |
+
|
| 890 |
+
send_btn.click(user, [msg_input, chatbot], [msg_input, chatbot], queue=False).then(
|
| 891 |
+
bot, [chatbot, session_id, user_emails, pending_rules, applied_rules], [chatbot, user_emails, pending_rules]
|
| 892 |
+
)
|
| 893 |
+
|
| 894 |
+
# Quick action handlers
|
| 895 |
+
def analyze_inbox():
|
| 896 |
+
return "Analyze my inbox and suggest organization rules"
|
| 897 |
+
|
| 898 |
+
def suggest_rules():
|
| 899 |
+
return "Suggest some email organization rules based on common patterns"
|
| 900 |
+
|
| 901 |
+
analyze_btn.click(analyze_inbox, None, msg_input).then(
|
| 902 |
+
user, [msg_input, chatbot], [msg_input, chatbot], queue=False
|
| 903 |
+
).then(
|
| 904 |
+
bot, [chatbot, session_id, user_emails, pending_rules, applied_rules], [chatbot, user_emails, pending_rules]
|
| 905 |
+
)
|
| 906 |
+
|
| 907 |
+
suggest_btn.click(suggest_rules, None, msg_input).then(
|
| 908 |
+
user, [msg_input, chatbot], [msg_input, chatbot], queue=False
|
| 909 |
+
).then(
|
| 910 |
+
bot, [chatbot, session_id, user_emails, pending_rules, applied_rules], [chatbot, user_emails, pending_rules]
|
| 911 |
+
)
|
| 912 |
+
|
| 913 |
+
# Email display handlers
|
| 914 |
+
def update_emails(folder, sort_option, search_query, emails_state):
|
| 915 |
+
filtered_emails = filter_emails(folder, search_query, sort_option, emails_state)
|
| 916 |
+
return create_email_html(filtered_emails, folder)
|
| 917 |
+
|
| 918 |
+
folder_dropdown.change(
|
| 919 |
+
update_emails,
|
| 920 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, user_emails],
|
| 921 |
+
outputs=[email_display]
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
sort_dropdown.change(
|
| 925 |
+
update_emails,
|
| 926 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, user_emails],
|
| 927 |
+
outputs=[email_display]
|
| 928 |
+
)
|
| 929 |
+
|
| 930 |
+
search_box.change(
|
| 931 |
+
update_emails,
|
| 932 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, user_emails],
|
| 933 |
+
outputs=[email_display]
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
# Initialize session on load
|
| 937 |
+
def init_session():
|
| 938 |
+
"""Initialize a new session for the user"""
|
| 939 |
+
new_session_id = session_manager.create_session()
|
| 940 |
+
logger.info(f"Created new session: {new_session_id}")
|
| 941 |
+
|
| 942 |
+
# Test: Load any existing rules from JSON
|
| 943 |
+
test_rules = []
|
| 944 |
+
if os.path.exists(RULES_DATA_PATH):
|
| 945 |
+
try:
|
| 946 |
+
with open(RULES_DATA_PATH, 'r') as f:
|
| 947 |
+
data = json.load(f)
|
| 948 |
+
for rule in data.get('rules', []):
|
| 949 |
+
test_rules.append({
|
| 950 |
+
'id': rule.get('rule_id', f'rule_{len(test_rules)}'),
|
| 951 |
+
'name': rule.get('name', 'Test Rule'),
|
| 952 |
+
'description': rule.get('description', 'Test description'),
|
| 953 |
+
'conditions': rule.get('conditions', []),
|
| 954 |
+
'actions': rule.get('actions', []),
|
| 955 |
+
'status': 'pending'
|
| 956 |
+
})
|
| 957 |
+
logger.info(f"Loaded {len(test_rules)} test rules from JSON")
|
| 958 |
+
except Exception as e:
|
| 959 |
+
logger.error(f"Error loading test rules: {e}")
|
| 960 |
+
|
| 961 |
+
return new_session_id, test_rules
|
| 962 |
+
|
| 963 |
+
# Initialize session on load and test with rules
|
| 964 |
+
demo.load(
|
| 965 |
+
init_session,
|
| 966 |
+
outputs=[session_id, pending_rules]
|
| 967 |
+
)
|
| 968 |
+
|
| 969 |
+
return demo
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
def launch_app(demo: gr.Blocks):
|
| 973 |
+
"""Launch the Gradio app"""
|
| 974 |
+
pass
|
archive/ui_old/archive/ui_gradio_render.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modern Gradio implementation using gr.render() for dynamic components
|
| 3 |
+
This is the recommended approach for Gradio 4.0+
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
from typing import List, Dict, Any, Tuple, Optional
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class RuleAction:
|
| 14 |
+
"""Encapsulates a rule action result"""
|
| 15 |
+
status_message: str
|
| 16 |
+
updated_rules: List[Dict]
|
| 17 |
+
show_preview: bool = False
|
| 18 |
+
affected_emails: Optional[List[Dict]] = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def create_rule_card_styles():
|
| 22 |
+
"""CSS styles for rule cards"""
|
| 23 |
+
return """
|
| 24 |
+
.rule-card {
|
| 25 |
+
border: 1px solid #e0e0e0;
|
| 26 |
+
border-radius: 8px;
|
| 27 |
+
padding: 16px;
|
| 28 |
+
margin-bottom: 12px;
|
| 29 |
+
transition: all 0.3s ease;
|
| 30 |
+
}
|
| 31 |
+
.rule-card:hover {
|
| 32 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
| 33 |
+
}
|
| 34 |
+
.rule-card.pending {
|
| 35 |
+
background-color: #f8f9fa;
|
| 36 |
+
}
|
| 37 |
+
.rule-card.accepted {
|
| 38 |
+
background-color: #d4edda;
|
| 39 |
+
border-color: #28a745;
|
| 40 |
+
}
|
| 41 |
+
.rule-card.rejected {
|
| 42 |
+
background-color: #f8d7da;
|
| 43 |
+
border-color: #dc3545;
|
| 44 |
+
opacity: 0.7;
|
| 45 |
+
}
|
| 46 |
+
.rule-card.preview {
|
| 47 |
+
background-color: #fff3cd;
|
| 48 |
+
border-color: #ffc107;
|
| 49 |
+
border-width: 2px;
|
| 50 |
+
}
|
| 51 |
+
.status-badge {
|
| 52 |
+
display: inline-block;
|
| 53 |
+
padding: 4px 12px;
|
| 54 |
+
border-radius: 20px;
|
| 55 |
+
font-size: 12px;
|
| 56 |
+
font-weight: 600;
|
| 57 |
+
text-transform: uppercase;
|
| 58 |
+
}
|
| 59 |
+
.status-pending { background: #ffc107; color: white; }
|
| 60 |
+
.status-accepted { background: #28a745; color: white; }
|
| 61 |
+
.status-rejected { background: #dc3545; color: white; }
|
| 62 |
+
.status-preview { background: #fd7e14; color: white; }
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def create_modern_gradio_app(modal_url: str) -> gr.Blocks:
|
| 67 |
+
"""
|
| 68 |
+
Create a modern Gradio app using gr.render() for dynamic components
|
| 69 |
+
This is the recommended approach for Gradio 4.0+
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
# Initialize with demo data
|
| 73 |
+
initial_rules = [
|
| 74 |
+
{
|
| 75 |
+
"id": "rule_1",
|
| 76 |
+
"name": "Newsletter Organizer",
|
| 77 |
+
"description": "Automatically move newsletters to a dedicated folder",
|
| 78 |
+
"conditions": [
|
| 79 |
+
{"field": "subject", "operator": "contains", "value": "newsletter"},
|
| 80 |
+
{"field": "from", "operator": "contains", "value": "noreply"}
|
| 81 |
+
],
|
| 82 |
+
"actions": [
|
| 83 |
+
{"type": "move", "parameters": {"folder": "Newsletters"}}
|
| 84 |
+
],
|
| 85 |
+
"status": "pending",
|
| 86 |
+
"created_at": datetime.now().isoformat()
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"id": "rule_2",
|
| 90 |
+
"name": "Work Email Prioritizer",
|
| 91 |
+
"description": "Flag important work emails from colleagues",
|
| 92 |
+
"conditions": [
|
| 93 |
+
{"field": "from", "operator": "contains", "value": "@company.com"}
|
| 94 |
+
],
|
| 95 |
+
"actions": [
|
| 96 |
+
{"type": "flag", "parameters": {"importance": "high"}},
|
| 97 |
+
{"type": "move", "parameters": {"folder": "Work"}}
|
| 98 |
+
],
|
| 99 |
+
"status": "pending",
|
| 100 |
+
"created_at": datetime.now().isoformat()
|
| 101 |
+
}
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
with gr.Blocks(
|
| 105 |
+
title="Email Rule Agent - Modern Implementation",
|
| 106 |
+
theme=gr.themes.Soft(),
|
| 107 |
+
css=create_rule_card_styles()
|
| 108 |
+
) as demo:
|
| 109 |
+
# State management
|
| 110 |
+
pending_rules = gr.State(initial_rules)
|
| 111 |
+
applied_rules = gr.State([])
|
| 112 |
+
preview_mode = gr.State(False)
|
| 113 |
+
selected_rule_id = gr.State(None)
|
| 114 |
+
|
| 115 |
+
gr.Markdown("# 📧 Email Rule Agent")
|
| 116 |
+
gr.Markdown("*Modern implementation using gr.render() for dynamic components*")
|
| 117 |
+
|
| 118 |
+
with gr.Row():
|
| 119 |
+
# Left column - Rules
|
| 120 |
+
with gr.Column(scale=5):
|
| 121 |
+
gr.Markdown("### 📋 Email Organization Rules")
|
| 122 |
+
|
| 123 |
+
# Status display
|
| 124 |
+
status_display = gr.Markdown("Ready to process rules...", elem_id="status-display")
|
| 125 |
+
|
| 126 |
+
# Rule statistics
|
| 127 |
+
@gr.render(inputs=[pending_rules, applied_rules])
|
| 128 |
+
def render_rule_stats(pending, applied):
|
| 129 |
+
total_pending = len([r for r in pending if r.get("status") != "rejected"])
|
| 130 |
+
total_applied = len(applied)
|
| 131 |
+
total_rejected = len([r for r in pending if r.get("status") == "rejected"])
|
| 132 |
+
|
| 133 |
+
with gr.Row():
|
| 134 |
+
gr.Markdown(f"**Pending:** {total_pending}")
|
| 135 |
+
gr.Markdown(f"**Applied:** {total_applied}")
|
| 136 |
+
gr.Markdown(f"**Rejected:** {total_rejected}")
|
| 137 |
+
|
| 138 |
+
# Dynamic rule cards using gr.render
|
| 139 |
+
@gr.render(inputs=[pending_rules, selected_rule_id])
|
| 140 |
+
def render_rule_cards(rules, selected_id):
|
| 141 |
+
if not rules:
|
| 142 |
+
with gr.Group(elem_classes=["rule-card"]):
|
| 143 |
+
gr.Markdown("### No pending rules")
|
| 144 |
+
gr.Markdown("Ask the AI assistant to analyze your inbox!")
|
| 145 |
+
else:
|
| 146 |
+
for rule in rules:
|
| 147 |
+
with gr.Group(elem_classes=[f"rule-card {rule['status']}"]):
|
| 148 |
+
with gr.Row():
|
| 149 |
+
with gr.Column(scale=4):
|
| 150 |
+
gr.Markdown(f"### {rule['name']}")
|
| 151 |
+
with gr.Column(scale=1):
|
| 152 |
+
status_emoji = {
|
| 153 |
+
"pending": "⏳",
|
| 154 |
+
"accepted": "✅",
|
| 155 |
+
"rejected": "❌",
|
| 156 |
+
"preview": "👁️"
|
| 157 |
+
}
|
| 158 |
+
gr.HTML(
|
| 159 |
+
f'<span class="status-badge status-{rule["status"]}">'
|
| 160 |
+
f'{status_emoji.get(rule["status"], "")} {rule["status"]}'
|
| 161 |
+
f'</span>'
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
gr.Markdown(rule['description'])
|
| 165 |
+
|
| 166 |
+
# Conditions
|
| 167 |
+
with gr.Accordion("Conditions", open=True):
|
| 168 |
+
for condition in rule['conditions']:
|
| 169 |
+
gr.Markdown(
|
| 170 |
+
f"• **{condition['field']}** "
|
| 171 |
+
f"{condition['operator']} "
|
| 172 |
+
f"*{condition['value']}*"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Actions
|
| 176 |
+
with gr.Accordion("Actions", open=True):
|
| 177 |
+
for action in rule['actions']:
|
| 178 |
+
if action['type'] == 'move':
|
| 179 |
+
gr.Markdown(
|
| 180 |
+
f"• Move to folder: **{action['parameters']['folder']}**"
|
| 181 |
+
)
|
| 182 |
+
else:
|
| 183 |
+
gr.Markdown(f"• {action['type']}")
|
| 184 |
+
|
| 185 |
+
# Action buttons based on status
|
| 186 |
+
if rule['status'] == 'pending':
|
| 187 |
+
with gr.Row():
|
| 188 |
+
preview_btn = gr.Button(
|
| 189 |
+
"👁️ Preview",
|
| 190 |
+
size="sm",
|
| 191 |
+
variant="secondary"
|
| 192 |
+
)
|
| 193 |
+
accept_btn = gr.Button(
|
| 194 |
+
"✅ Accept",
|
| 195 |
+
size="sm",
|
| 196 |
+
variant="primary"
|
| 197 |
+
)
|
| 198 |
+
reject_btn = gr.Button(
|
| 199 |
+
"❌ Reject",
|
| 200 |
+
size="sm",
|
| 201 |
+
variant="stop"
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# Event handlers with closures to capture rule_id
|
| 205 |
+
preview_btn.click(
|
| 206 |
+
fn=lambda r=rule: handle_preview(r['id']),
|
| 207 |
+
inputs=[pending_rules],
|
| 208 |
+
outputs=[pending_rules, status_display, preview_mode]
|
| 209 |
+
)
|
| 210 |
+
accept_btn.click(
|
| 211 |
+
fn=lambda r=rule: handle_accept(r['id']),
|
| 212 |
+
inputs=[pending_rules, applied_rules],
|
| 213 |
+
outputs=[pending_rules, applied_rules, status_display]
|
| 214 |
+
)
|
| 215 |
+
reject_btn.click(
|
| 216 |
+
fn=lambda r=rule: handle_reject(r['id']),
|
| 217 |
+
inputs=[pending_rules],
|
| 218 |
+
outputs=[pending_rules, status_display]
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
elif rule['status'] == 'preview':
|
| 222 |
+
with gr.Row():
|
| 223 |
+
accept_btn = gr.Button(
|
| 224 |
+
"✅ Accept & Apply",
|
| 225 |
+
size="sm",
|
| 226 |
+
variant="primary"
|
| 227 |
+
)
|
| 228 |
+
cancel_btn = gr.Button(
|
| 229 |
+
"Cancel Preview",
|
| 230 |
+
size="sm",
|
| 231 |
+
variant="secondary"
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
accept_btn.click(
|
| 235 |
+
fn=lambda r=rule: handle_accept(r['id']),
|
| 236 |
+
inputs=[pending_rules, applied_rules],
|
| 237 |
+
outputs=[pending_rules, applied_rules, status_display]
|
| 238 |
+
)
|
| 239 |
+
cancel_btn.click(
|
| 240 |
+
fn=lambda r=rule: handle_cancel_preview(r['id']),
|
| 241 |
+
inputs=[pending_rules],
|
| 242 |
+
outputs=[pending_rules, status_display, preview_mode]
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# Right column - Email preview
|
| 246 |
+
with gr.Column(scale=6):
|
| 247 |
+
# Preview mode banner
|
| 248 |
+
@gr.render(inputs=[preview_mode, pending_rules])
|
| 249 |
+
def render_preview_banner(is_preview, rules):
|
| 250 |
+
preview_rules = [r for r in rules if r.get('status') == 'preview']
|
| 251 |
+
if is_preview and preview_rules:
|
| 252 |
+
with gr.Group(elem_classes=["preview-banner"]):
|
| 253 |
+
gr.Markdown(
|
| 254 |
+
f"👁️ **Preview Mode Active** - "
|
| 255 |
+
f"Showing how {len(preview_rules)} rule(s) would organize your emails"
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
# Email display placeholder
|
| 259 |
+
gr.Markdown("### 📧 Email Inbox")
|
| 260 |
+
email_display = gr.HTML(
|
| 261 |
+
value="<p>Email display would be here. "
|
| 262 |
+
"This demo focuses on the rule card implementation.</p>"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# Handler functions
|
| 266 |
+
def handle_preview(rule_id: str) -> Tuple[List[Dict], str, bool]:
|
| 267 |
+
def update_rules(rules):
|
| 268 |
+
for rule in rules:
|
| 269 |
+
if rule['id'] == rule_id:
|
| 270 |
+
rule['status'] = 'preview'
|
| 271 |
+
return rules, f"👁️ Previewing rule: {rule['name']}", True
|
| 272 |
+
return rules, "Rule not found", False
|
| 273 |
+
|
| 274 |
+
return update_rules
|
| 275 |
+
|
| 276 |
+
def handle_accept(rule_id: str):
|
| 277 |
+
def update_rules(pending, applied):
|
| 278 |
+
for i, rule in enumerate(pending):
|
| 279 |
+
if rule['id'] == rule_id:
|
| 280 |
+
rule['status'] = 'accepted'
|
| 281 |
+
applied.append(rule)
|
| 282 |
+
pending.pop(i)
|
| 283 |
+
return pending, applied, f"✅ Rule accepted and applied: {rule['name']}"
|
| 284 |
+
return pending, applied, "Rule not found"
|
| 285 |
+
|
| 286 |
+
return update_rules
|
| 287 |
+
|
| 288 |
+
def handle_reject(rule_id: str):
|
| 289 |
+
def update_rules(rules):
|
| 290 |
+
for rule in rules:
|
| 291 |
+
if rule['id'] == rule_id:
|
| 292 |
+
rule['status'] = 'rejected'
|
| 293 |
+
return rules, f"❌ Rule rejected: {rule['name']}"
|
| 294 |
+
return rules, "Rule not found"
|
| 295 |
+
|
| 296 |
+
return update_rules
|
| 297 |
+
|
| 298 |
+
def handle_cancel_preview(rule_id: str):
|
| 299 |
+
def update_rules(rules):
|
| 300 |
+
for rule in rules:
|
| 301 |
+
if rule['id'] == rule_id and rule['status'] == 'preview':
|
| 302 |
+
rule['status'] = 'pending'
|
| 303 |
+
return rules, "Preview cancelled", False
|
| 304 |
+
return rules, "Rule not found", False
|
| 305 |
+
|
| 306 |
+
return update_rules
|
| 307 |
+
|
| 308 |
+
# Add new rule button for testing
|
| 309 |
+
with gr.Row():
|
| 310 |
+
add_rule_btn = gr.Button("➕ Add Demo Rule", variant="secondary")
|
| 311 |
+
|
| 312 |
+
@add_rule_btn.click(inputs=[pending_rules], outputs=[pending_rules, status_display])
|
| 313 |
+
def add_demo_rule(rules):
|
| 314 |
+
new_rule = {
|
| 315 |
+
"id": f"rule_{len(rules) + 1}",
|
| 316 |
+
"name": f"Demo Rule {len(rules) + 1}",
|
| 317 |
+
"description": "A dynamically added rule for demonstration",
|
| 318 |
+
"conditions": [
|
| 319 |
+
{"field": "subject", "operator": "contains", "value": "demo"}
|
| 320 |
+
],
|
| 321 |
+
"actions": [
|
| 322 |
+
{"type": "move", "parameters": {"folder": "Demo"}}
|
| 323 |
+
],
|
| 324 |
+
"status": "pending",
|
| 325 |
+
"created_at": datetime.now().isoformat()
|
| 326 |
+
}
|
| 327 |
+
rules.append(new_rule)
|
| 328 |
+
return rules, f"Added new rule: {new_rule['name']}"
|
| 329 |
+
|
| 330 |
+
return demo
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
# Alternative approach using fixed components with dynamic visibility
|
| 334 |
+
def create_fixed_component_app(modal_url: str) -> gr.Blocks:
|
| 335 |
+
"""
|
| 336 |
+
Alternative approach using pre-created components with visibility control
|
| 337 |
+
This works well when you have a maximum number of rules
|
| 338 |
+
"""
|
| 339 |
+
MAX_RULES = 10
|
| 340 |
+
|
| 341 |
+
with gr.Blocks(title="Email Rule Agent - Fixed Components") as demo:
|
| 342 |
+
gr.Markdown("# 📧 Email Rule Agent")
|
| 343 |
+
gr.Markdown("*Implementation using fixed components with dynamic visibility*")
|
| 344 |
+
|
| 345 |
+
# State
|
| 346 |
+
rules_data = gr.State([])
|
| 347 |
+
|
| 348 |
+
# Create fixed rule card components
|
| 349 |
+
rule_cards = []
|
| 350 |
+
for i in range(MAX_RULES):
|
| 351 |
+
with gr.Group(visible=False) as card_group:
|
| 352 |
+
with gr.Row():
|
| 353 |
+
rule_name = gr.Markdown()
|
| 354 |
+
rule_status = gr.Markdown()
|
| 355 |
+
|
| 356 |
+
rule_desc = gr.Markdown()
|
| 357 |
+
|
| 358 |
+
with gr.Row():
|
| 359 |
+
preview_btn = gr.Button("Preview", size="sm", variant="secondary")
|
| 360 |
+
accept_btn = gr.Button("Accept", size="sm", variant="primary")
|
| 361 |
+
reject_btn = gr.Button("Reject", size="sm", variant="stop")
|
| 362 |
+
|
| 363 |
+
# Store references
|
| 364 |
+
rule_cards.append({
|
| 365 |
+
"group": card_group,
|
| 366 |
+
"name": rule_name,
|
| 367 |
+
"status": rule_status,
|
| 368 |
+
"desc": rule_desc,
|
| 369 |
+
"preview": preview_btn,
|
| 370 |
+
"accept": accept_btn,
|
| 371 |
+
"reject": reject_btn
|
| 372 |
+
})
|
| 373 |
+
|
| 374 |
+
# Update function to show/hide and populate cards
|
| 375 |
+
def update_rule_display(rules):
|
| 376 |
+
updates = []
|
| 377 |
+
for i, card in enumerate(rule_cards):
|
| 378 |
+
if i < len(rules):
|
| 379 |
+
rule = rules[i]
|
| 380 |
+
updates.extend([
|
| 381 |
+
gr.update(visible=True), # group
|
| 382 |
+
gr.update(value=f"### {rule['name']}"), # name
|
| 383 |
+
gr.update(value=f"Status: {rule['status']}"), # status
|
| 384 |
+
gr.update(value=rule['description']) # desc
|
| 385 |
+
])
|
| 386 |
+
else:
|
| 387 |
+
updates.extend([
|
| 388 |
+
gr.update(visible=False), # group
|
| 389 |
+
gr.update(), # name
|
| 390 |
+
gr.update(), # status
|
| 391 |
+
gr.update() # desc
|
| 392 |
+
])
|
| 393 |
+
return updates
|
| 394 |
+
|
| 395 |
+
return demo
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
if __name__ == "__main__":
|
| 399 |
+
print("MODERN GRADIO IMPLEMENTATION APPROACHES")
|
| 400 |
+
print("=" * 60)
|
| 401 |
+
print("\n1. gr.render() APPROACH (Recommended for Gradio 4.0+):")
|
| 402 |
+
print(" ✅ Dynamic component creation")
|
| 403 |
+
print(" ✅ Proper event handling with closures")
|
| 404 |
+
print(" ✅ Clean, maintainable code")
|
| 405 |
+
print(" ✅ Scales to any number of rules")
|
| 406 |
+
print("\n2. FIXED COMPONENTS APPROACH:")
|
| 407 |
+
print(" ✅ Works with older Gradio versions")
|
| 408 |
+
print(" ✅ Simple event handling")
|
| 409 |
+
print(" ❌ Limited by MAX_RULES")
|
| 410 |
+
print(" ❌ More complex update logic")
|
| 411 |
+
print("\n3. HYBRID APPROACH:")
|
| 412 |
+
print(" - Use gr.render() for rule display")
|
| 413 |
+
print(" - Use fixed action buttons with rule selector")
|
| 414 |
+
print(" - Good balance of flexibility and simplicity")
|
archive/ui_old/archive/ui_handlers_fix.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fix for event handlers in @gr.render
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
# The issue: Event handlers defined inside @gr.render get stale references
|
| 6 |
+
# Solution: Define handlers outside and use a dispatcher pattern
|
| 7 |
+
|
| 8 |
+
def create_rule_handlers(mcp_client):
|
| 9 |
+
"""Create event handlers that can be used with dynamic rule IDs"""
|
| 10 |
+
|
| 11 |
+
def preview_handler(rule_id, *args):
|
| 12 |
+
"""Generic preview handler that receives rule_id as first argument"""
|
| 13 |
+
# Extract the actual arguments
|
| 14 |
+
sort_option, search_query, user_emails, preview_emails, pending_rules = args
|
| 15 |
+
return preview_rule_with_mcp(
|
| 16 |
+
rule_id, sort_option, search_query, mcp_client,
|
| 17 |
+
user_emails, preview_emails, pending_rules
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
def accept_handler(rule_id, *args):
|
| 21 |
+
"""Generic accept handler that receives rule_id as first argument"""
|
| 22 |
+
# Extract the actual arguments
|
| 23 |
+
current_folder, sort_option, search_query, user_emails, preview_emails, pending_rules, applied_rules = args
|
| 24 |
+
return accept_rule_with_mcp(
|
| 25 |
+
rule_id, mcp_client, current_folder, sort_option, search_query,
|
| 26 |
+
user_emails, preview_emails, pending_rules, applied_rules
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
def reject_handler(rule_id, pending_rules):
|
| 30 |
+
"""Generic reject handler"""
|
| 31 |
+
updated_rules = pending_rules.copy()
|
| 32 |
+
for rule in updated_rules:
|
| 33 |
+
if rule['id'] == rule_id:
|
| 34 |
+
rule['status'] = 'rejected'
|
| 35 |
+
break
|
| 36 |
+
|
| 37 |
+
banner = create_preview_banner(get_preview_rules_count(updated_rules))
|
| 38 |
+
status = f"❌ Rule rejected"
|
| 39 |
+
return updated_rules, banner, status
|
| 40 |
+
|
| 41 |
+
return preview_handler, accept_handler, reject_handler
|
archive/ui_old/archive/ui_refactored_example.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Practical refactoring example: Converting HTML string approach to Gradio components
|
| 3 |
+
This shows how to refactor the existing create_interactive_rule_cards function
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
from typing import List, Dict, Any, Tuple, Callable
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# Original HTML approach (for reference)
|
| 12 |
+
def create_interactive_rule_cards_original(pending_rules: List[Dict]) -> str:
|
| 13 |
+
"""Original HTML string approach - what we're refactoring FROM"""
|
| 14 |
+
if not pending_rules:
|
| 15 |
+
return """<div>No pending rules...</div>"""
|
| 16 |
+
|
| 17 |
+
html = "<div>"
|
| 18 |
+
for rule in pending_rules:
|
| 19 |
+
# ... HTML string building ...
|
| 20 |
+
pass
|
| 21 |
+
html += "</div>"
|
| 22 |
+
return html
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# REFACTORED APPROACH 1: Using gr.HTML with better structure
|
| 26 |
+
def create_rule_cards_with_structured_html(pending_rules: List[Dict]) -> Tuple[str, str]:
|
| 27 |
+
"""
|
| 28 |
+
Improved HTML approach with separate CSS and cleaner structure
|
| 29 |
+
Returns: (html, css)
|
| 30 |
+
"""
|
| 31 |
+
css = """
|
| 32 |
+
<style>
|
| 33 |
+
.rule-container { max-height: 400px; overflow-y: auto; padding: 10px; }
|
| 34 |
+
.rule-card {
|
| 35 |
+
border: 2px solid #e1e5e9;
|
| 36 |
+
border-radius: 12px;
|
| 37 |
+
padding: 20px;
|
| 38 |
+
margin: 10px 0;
|
| 39 |
+
background: white;
|
| 40 |
+
transition: all 0.2s;
|
| 41 |
+
}
|
| 42 |
+
.rule-card.pending { background: white; }
|
| 43 |
+
.rule-card.accepted { background: #d4edda; border-color: #28a745; }
|
| 44 |
+
.rule-card.rejected { background: #f8d7da; border-color: #dc3545; opacity: 0.6; }
|
| 45 |
+
.rule-card.preview { background: #fff3cd; border-color: #fd7e14; }
|
| 46 |
+
.rule-actions { display: flex; gap: 8px; margin-top: 15px; }
|
| 47 |
+
.rule-btn {
|
| 48 |
+
padding: 8px 16px;
|
| 49 |
+
border: none;
|
| 50 |
+
border-radius: 6px;
|
| 51 |
+
cursor: pointer;
|
| 52 |
+
font-size: 13px;
|
| 53 |
+
font-weight: 500;
|
| 54 |
+
transition: all 0.2s;
|
| 55 |
+
}
|
| 56 |
+
.rule-btn:hover { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
| 57 |
+
.btn-preview { background: white; color: #007bff; border: 1px solid #007bff; }
|
| 58 |
+
.btn-accept { background: #28a745; color: white; }
|
| 59 |
+
.btn-reject { background: #dc3545; color: white; }
|
| 60 |
+
</style>
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
if not pending_rules:
|
| 64 |
+
html = """
|
| 65 |
+
<div class="rule-container">
|
| 66 |
+
<div style="text-align: center; padding: 40px;">
|
| 67 |
+
<div style="font-size: 48px;">📋</div>
|
| 68 |
+
<h3>No Pending Rules</h3>
|
| 69 |
+
<p>Ask me to analyze your inbox to get started!</p>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
"""
|
| 73 |
+
return html, css
|
| 74 |
+
|
| 75 |
+
# Build HTML with data attributes for easier JS interaction
|
| 76 |
+
html = '<div class="rule-container">'
|
| 77 |
+
html += '<div class="rule-header"><h3>📋 Email Rules</h3></div>'
|
| 78 |
+
|
| 79 |
+
for rule in pending_rules:
|
| 80 |
+
rule_id = rule['id']
|
| 81 |
+
html += f'''
|
| 82 |
+
<div class="rule-card {rule['status']}" data-rule-id="{rule_id}">
|
| 83 |
+
<div class="rule-header">
|
| 84 |
+
<h4>{rule.get('name', 'Unnamed Rule')}</h4>
|
| 85 |
+
<span class="status-{rule['status']}">{rule['status'].upper()}</span>
|
| 86 |
+
</div>
|
| 87 |
+
<p>{rule.get('description', '')}</p>
|
| 88 |
+
<div class="rule-conditions">
|
| 89 |
+
<strong>Conditions:</strong>
|
| 90 |
+
<ul>{''.join(f"<li>{cond}</li>" for cond in rule.get('conditions', []))}</ul>
|
| 91 |
+
</div>
|
| 92 |
+
<div class="rule-actions-list">
|
| 93 |
+
<strong>Actions:</strong>
|
| 94 |
+
<ul>{''.join(f"<li>{action}</li>" for action in rule.get('actions', []))}</ul>
|
| 95 |
+
</div>
|
| 96 |
+
'''
|
| 97 |
+
|
| 98 |
+
if rule['status'] == 'pending':
|
| 99 |
+
html += f'''
|
| 100 |
+
<div class="rule-actions">
|
| 101 |
+
<button class="rule-btn btn-preview" onclick="handleRuleAction('preview', '{rule_id}')">
|
| 102 |
+
👁️ Preview
|
| 103 |
+
</button>
|
| 104 |
+
<button class="rule-btn btn-accept" onclick="handleRuleAction('accept', '{rule_id}')">
|
| 105 |
+
✅ Accept
|
| 106 |
+
</button>
|
| 107 |
+
<button class="rule-btn btn-reject" onclick="handleRuleAction('reject', '{rule_id}')">
|
| 108 |
+
❌ Reject
|
| 109 |
+
</button>
|
| 110 |
+
</div>
|
| 111 |
+
'''
|
| 112 |
+
|
| 113 |
+
html += '</div>'
|
| 114 |
+
|
| 115 |
+
html += '</div>'
|
| 116 |
+
|
| 117 |
+
# Add improved JavaScript
|
| 118 |
+
js = """
|
| 119 |
+
<script>
|
| 120 |
+
function handleRuleAction(action, ruleId) {
|
| 121 |
+
// Dispatch custom event that Gradio can listen to
|
| 122 |
+
const event = new CustomEvent('ruleAction', {
|
| 123 |
+
detail: { action: action, ruleId: ruleId }
|
| 124 |
+
});
|
| 125 |
+
document.dispatchEvent(event);
|
| 126 |
+
|
| 127 |
+
// Update UI optimistically
|
| 128 |
+
const card = document.querySelector(`[data-rule-id="${ruleId}"]`);
|
| 129 |
+
if (card) {
|
| 130 |
+
card.style.opacity = '0.5';
|
| 131 |
+
card.style.pointerEvents = 'none';
|
| 132 |
+
}
|
| 133 |
+
}
|
| 134 |
+
</script>
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
return css + html + js, ""
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# REFACTORED APPROACH 2: Gradio-native with rule selector
|
| 141 |
+
def create_rule_interface_with_selector() -> gr.Blocks:
|
| 142 |
+
"""
|
| 143 |
+
Gradio-native approach using a rule selector dropdown
|
| 144 |
+
This avoids the dynamic component problem
|
| 145 |
+
"""
|
| 146 |
+
with gr.Blocks() as interface:
|
| 147 |
+
with gr.Row():
|
| 148 |
+
# Rule selector and details
|
| 149 |
+
with gr.Column(scale=1):
|
| 150 |
+
gr.Markdown("### 📋 Email Rules")
|
| 151 |
+
|
| 152 |
+
rule_selector = gr.Dropdown(
|
| 153 |
+
label="Select a rule to view details",
|
| 154 |
+
choices=[],
|
| 155 |
+
value=None,
|
| 156 |
+
interactive=True
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Rule details group
|
| 160 |
+
with gr.Group(visible=False) as rule_details:
|
| 161 |
+
rule_name = gr.Markdown("### Rule Name")
|
| 162 |
+
rule_description = gr.Markdown("Description")
|
| 163 |
+
|
| 164 |
+
with gr.Accordion("Conditions", open=True):
|
| 165 |
+
conditions_text = gr.Markdown("No conditions")
|
| 166 |
+
|
| 167 |
+
with gr.Accordion("Actions", open=True):
|
| 168 |
+
actions_text = gr.Markdown("No actions")
|
| 169 |
+
|
| 170 |
+
rule_status = gr.Markdown("**Status:** Pending")
|
| 171 |
+
|
| 172 |
+
# Action buttons
|
| 173 |
+
with gr.Row() as action_buttons:
|
| 174 |
+
preview_btn = gr.Button("👁️ Preview", variant="secondary")
|
| 175 |
+
accept_btn = gr.Button("✅ Accept", variant="primary")
|
| 176 |
+
reject_btn = gr.Button("❌ Reject", variant="stop")
|
| 177 |
+
|
| 178 |
+
# Rule list summary
|
| 179 |
+
with gr.Column(scale=1):
|
| 180 |
+
gr.Markdown("### Rule Summary")
|
| 181 |
+
rule_summary = gr.DataFrame(
|
| 182 |
+
headers=["Name", "Status", "Conditions", "Actions"],
|
| 183 |
+
datatype=["str", "str", "number", "number"],
|
| 184 |
+
col_count=(4, "fixed"),
|
| 185 |
+
interactive=False
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# Update functions
|
| 189 |
+
def update_rule_display(selected_rule_name, rules):
|
| 190 |
+
if not selected_rule_name or not rules:
|
| 191 |
+
return [gr.update(visible=False)] * 8
|
| 192 |
+
|
| 193 |
+
rule = next((r for r in rules if r['name'] == selected_rule_name), None)
|
| 194 |
+
if not rule:
|
| 195 |
+
return [gr.update(visible=False)] * 8
|
| 196 |
+
|
| 197 |
+
# Format conditions and actions
|
| 198 |
+
conditions = "\n".join([f"• {c}" for c in rule.get('conditions', [])])
|
| 199 |
+
actions = "\n".join([f"• {a}" for a in rule.get('actions', [])])
|
| 200 |
+
|
| 201 |
+
return [
|
| 202 |
+
gr.update(visible=True), # rule_details group
|
| 203 |
+
gr.update(value=f"### {rule['name']}"), # name
|
| 204 |
+
gr.update(value=rule.get('description', '')), # description
|
| 205 |
+
gr.update(value=conditions or "No conditions"), # conditions
|
| 206 |
+
gr.update(value=actions or "No actions"), # actions
|
| 207 |
+
gr.update(value=f"**Status:** {rule['status'].title()}"), # status
|
| 208 |
+
gr.update(visible=rule['status'] == 'pending'), # action buttons
|
| 209 |
+
gr.update(visible=rule['status'] in ['pending', 'preview']) # some buttons
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
def update_rule_summary(rules):
|
| 213 |
+
summary_data = []
|
| 214 |
+
for rule in rules:
|
| 215 |
+
summary_data.append([
|
| 216 |
+
rule['name'],
|
| 217 |
+
rule['status'].title(),
|
| 218 |
+
len(rule.get('conditions', [])),
|
| 219 |
+
len(rule.get('actions', []))
|
| 220 |
+
])
|
| 221 |
+
return summary_data
|
| 222 |
+
|
| 223 |
+
return interface
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# REFACTORED APPROACH 3: Hybrid with custom component
|
| 227 |
+
class RuleCardComponent(gr.HTML):
|
| 228 |
+
"""
|
| 229 |
+
Custom Gradio component that extends HTML with better event handling
|
| 230 |
+
This is a conceptual example - actual implementation would need custom JS
|
| 231 |
+
"""
|
| 232 |
+
|
| 233 |
+
def __init__(self, pending_rules: List[Dict], **kwargs):
|
| 234 |
+
self.pending_rules = pending_rules
|
| 235 |
+
html_content = self._generate_html()
|
| 236 |
+
super().__init__(value=html_content, **kwargs)
|
| 237 |
+
|
| 238 |
+
def _generate_html(self) -> str:
|
| 239 |
+
"""Generate HTML with embedded event handlers"""
|
| 240 |
+
return create_rule_cards_with_structured_html(self.pending_rules)[0]
|
| 241 |
+
|
| 242 |
+
def update_rules(self, new_rules: List[Dict]) -> gr.update:
|
| 243 |
+
"""Update the component with new rules"""
|
| 244 |
+
self.pending_rules = new_rules
|
| 245 |
+
return gr.update(value=self._generate_html())
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# REFACTORED APPROACH 4: Using Gradio Blocks with @gr.render
|
| 249 |
+
def create_modern_rule_interface() -> gr.Blocks:
|
| 250 |
+
"""
|
| 251 |
+
Modern approach using @gr.render for dynamic components
|
| 252 |
+
This is the recommended approach for Gradio 4.0+
|
| 253 |
+
"""
|
| 254 |
+
with gr.Blocks() as interface:
|
| 255 |
+
# State
|
| 256 |
+
rules_state = gr.State([])
|
| 257 |
+
selected_rule = gr.State(None)
|
| 258 |
+
|
| 259 |
+
gr.Markdown("### 📋 Email Rules Management")
|
| 260 |
+
|
| 261 |
+
# Statistics row
|
| 262 |
+
@gr.render(inputs=rules_state)
|
| 263 |
+
def render_stats(rules):
|
| 264 |
+
pending = len([r for r in rules if r['status'] == 'pending'])
|
| 265 |
+
accepted = len([r for r in rules if r['status'] == 'accepted'])
|
| 266 |
+
rejected = len([r for r in rules if r['status'] == 'rejected'])
|
| 267 |
+
|
| 268 |
+
with gr.Row():
|
| 269 |
+
gr.Number(value=pending, label="Pending", interactive=False)
|
| 270 |
+
gr.Number(value=accepted, label="Accepted", interactive=False)
|
| 271 |
+
gr.Number(value=rejected, label="Rejected", interactive=False)
|
| 272 |
+
|
| 273 |
+
# Rule cards
|
| 274 |
+
@gr.render(inputs=[rules_state, selected_rule])
|
| 275 |
+
def render_rules(rules, selected):
|
| 276 |
+
if not rules:
|
| 277 |
+
gr.Markdown("No rules to display. Ask the AI to analyze your inbox!")
|
| 278 |
+
return
|
| 279 |
+
|
| 280 |
+
for rule in rules:
|
| 281 |
+
# Determine if this rule is selected
|
| 282 |
+
is_selected = selected and selected.get('id') == rule['id']
|
| 283 |
+
|
| 284 |
+
with gr.Group():
|
| 285 |
+
with gr.Row():
|
| 286 |
+
with gr.Column(scale=4):
|
| 287 |
+
# Make the rule name clickable
|
| 288 |
+
rule_btn = gr.Button(
|
| 289 |
+
value=rule['name'],
|
| 290 |
+
variant="secondary" if not is_selected else "primary",
|
| 291 |
+
size="sm"
|
| 292 |
+
)
|
| 293 |
+
rule_btn.click(
|
| 294 |
+
fn=lambda r=rule: r,
|
| 295 |
+
outputs=selected_rule
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
with gr.Column(scale=1):
|
| 299 |
+
status_colors = {
|
| 300 |
+
'pending': 'orange',
|
| 301 |
+
'accepted': 'green',
|
| 302 |
+
'rejected': 'red',
|
| 303 |
+
'preview': 'blue'
|
| 304 |
+
}
|
| 305 |
+
gr.HTML(
|
| 306 |
+
f"<span style='color: {status_colors.get(rule['status'], 'gray')}'>"
|
| 307 |
+
f"{rule['status'].upper()}</span>"
|
| 308 |
+
)
|
| 309 |
+
|
| 310 |
+
# Show details if selected
|
| 311 |
+
if is_selected:
|
| 312 |
+
gr.Markdown(rule.get('description', 'No description'))
|
| 313 |
+
|
| 314 |
+
with gr.Row():
|
| 315 |
+
if rule['status'] == 'pending':
|
| 316 |
+
preview_btn = gr.Button("Preview", size="sm")
|
| 317 |
+
accept_btn = gr.Button("Accept", size="sm", variant="primary")
|
| 318 |
+
reject_btn = gr.Button("Reject", size="sm", variant="stop")
|
| 319 |
+
|
| 320 |
+
# Bind events
|
| 321 |
+
preview_btn.click(
|
| 322 |
+
fn=lambda: update_rule_status(rule['id'], 'preview'),
|
| 323 |
+
inputs=rules_state,
|
| 324 |
+
outputs=rules_state
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
def update_rule_status(rule_id: str, new_status: str):
|
| 328 |
+
def updater(rules):
|
| 329 |
+
for rule in rules:
|
| 330 |
+
if rule['id'] == rule_id:
|
| 331 |
+
rule['status'] = new_status
|
| 332 |
+
return rules
|
| 333 |
+
return updater
|
| 334 |
+
|
| 335 |
+
return interface
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
# Example usage and migration guide
|
| 339 |
+
def migration_example():
|
| 340 |
+
"""
|
| 341 |
+
Shows how to migrate from HTML strings to Gradio components
|
| 342 |
+
"""
|
| 343 |
+
|
| 344 |
+
# Sample rules data
|
| 345 |
+
sample_rules = [
|
| 346 |
+
{
|
| 347 |
+
"id": "rule_1",
|
| 348 |
+
"name": "Newsletter Filter",
|
| 349 |
+
"description": "Move newsletters to dedicated folder",
|
| 350 |
+
"conditions": ["From contains 'newsletter'", "Subject contains 'weekly'"],
|
| 351 |
+
"actions": ["Move to 'Newsletters' folder"],
|
| 352 |
+
"status": "pending"
|
| 353 |
+
}
|
| 354 |
+
]
|
| 355 |
+
|
| 356 |
+
print("MIGRATION GUIDE: From HTML Strings to Native Gradio")
|
| 357 |
+
print("=" * 60)
|
| 358 |
+
print("\nSTEP 1: Identify the core functionality")
|
| 359 |
+
print("- Display rule cards")
|
| 360 |
+
print("- Handle preview/accept/reject actions")
|
| 361 |
+
print("- Update UI based on rule status")
|
| 362 |
+
|
| 363 |
+
print("\nSTEP 2: Choose the right approach")
|
| 364 |
+
print("- For Gradio 4.0+: Use @gr.render")
|
| 365 |
+
print("- For older versions: Use rule selector pattern")
|
| 366 |
+
print("- For complex interactions: Consider custom components")
|
| 367 |
+
|
| 368 |
+
print("\nSTEP 3: Refactor incrementally")
|
| 369 |
+
print("1. Start with better structured HTML")
|
| 370 |
+
print("2. Move to rule selector approach")
|
| 371 |
+
print("3. Gradually adopt @gr.render")
|
| 372 |
+
print("4. Add proper event handling")
|
| 373 |
+
|
| 374 |
+
print("\nKEY BENEFITS of native Gradio approach:")
|
| 375 |
+
print("✅ No JavaScript event handling complexity")
|
| 376 |
+
print("✅ Better state management")
|
| 377 |
+
print("✅ Automatic UI updates")
|
| 378 |
+
print("✅ Type-safe event handlers")
|
| 379 |
+
print("✅ Better accessibility")
|
| 380 |
+
print("✅ Easier to test and maintain")
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
if __name__ == "__main__":
|
| 384 |
+
migration_example()
|
archive/ui_old/archive/ui_simplified.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simplified UI handlers that properly delegate to MCP backend
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
from typing import List, Dict, Any, Tuple
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def preview_rule_simple(
|
| 10 |
+
rule_id: str,
|
| 11 |
+
mcp_client: Any,
|
| 12 |
+
pending_rules: List[Dict[str, Any]]
|
| 13 |
+
) -> Tuple[Dict[str, Any], str]:
|
| 14 |
+
"""
|
| 15 |
+
Simple preview handler that delegates to MCP
|
| 16 |
+
Returns: (preview_response, status_message)
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
if not rule_id or rule_id.strip() == "":
|
| 20 |
+
return {}, "❌ Error: No rule ID provided"
|
| 21 |
+
|
| 22 |
+
# Find the rule
|
| 23 |
+
rule = next((r for r in pending_rules if r["id"] == rule_id), None)
|
| 24 |
+
if not rule:
|
| 25 |
+
return {}, f"❌ Rule not found: {rule_id}"
|
| 26 |
+
|
| 27 |
+
try:
|
| 28 |
+
# Call MCP backend
|
| 29 |
+
response = mcp_client.preview_rule(rule)
|
| 30 |
+
|
| 31 |
+
if response.get('success', False):
|
| 32 |
+
# Update rule status locally
|
| 33 |
+
rule["status"] = "preview"
|
| 34 |
+
|
| 35 |
+
stats = response.get('statistics', {})
|
| 36 |
+
status_msg = f"👁️ Preview: {stats.get('matched_count', 0)} emails would be affected"
|
| 37 |
+
|
| 38 |
+
return response, status_msg
|
| 39 |
+
else:
|
| 40 |
+
return {}, "❌ Preview failed"
|
| 41 |
+
|
| 42 |
+
except Exception as e:
|
| 43 |
+
print(f"Error calling MCP preview: {e}")
|
| 44 |
+
return {}, f"❌ Error: {str(e)}"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def apply_rule_simple(
|
| 48 |
+
rule_id: str,
|
| 49 |
+
mcp_client: Any,
|
| 50 |
+
pending_rules: List[Dict[str, Any]],
|
| 51 |
+
applied_rules: List[Dict[str, Any]]
|
| 52 |
+
) -> Tuple[Dict[str, Any], str]:
|
| 53 |
+
"""
|
| 54 |
+
Simple apply handler that delegates to MCP
|
| 55 |
+
Returns: (apply_response, status_message)
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
if not rule_id or rule_id.strip() == "":
|
| 59 |
+
return {}, "❌ Error: No rule ID provided"
|
| 60 |
+
|
| 61 |
+
# Find the rule
|
| 62 |
+
rule = next((r for r in pending_rules if r["id"] == rule_id), None)
|
| 63 |
+
if not rule:
|
| 64 |
+
return {}, f"❌ Rule not found: {rule_id}"
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
# Apply via MCP
|
| 68 |
+
response = mcp_client.apply_rule(rule, preview=False)
|
| 69 |
+
|
| 70 |
+
if response.get('success', False):
|
| 71 |
+
# Save rule
|
| 72 |
+
mcp_client.save_rule(rule)
|
| 73 |
+
|
| 74 |
+
# Update status
|
| 75 |
+
rule["status"] = "accepted"
|
| 76 |
+
applied_rules.append(rule.copy())
|
| 77 |
+
|
| 78 |
+
stats = response.get('statistics', {})
|
| 79 |
+
status_msg = f"✅ Rule applied: {stats.get('processed_count', 0)} emails processed"
|
| 80 |
+
|
| 81 |
+
return response, status_msg
|
| 82 |
+
else:
|
| 83 |
+
return {}, "❌ Apply failed"
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"Error applying rule: {e}")
|
| 87 |
+
return {}, f"❌ Error: {str(e)}"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def get_current_email_view(
|
| 91 |
+
mcp_client: Any,
|
| 92 |
+
folder: str = "inbox",
|
| 93 |
+
preview_data: Dict[str, Any] = None
|
| 94 |
+
) -> Dict[str, Any]:
|
| 95 |
+
"""
|
| 96 |
+
Get the current email view from backend or preview data
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
if preview_data and preview_data.get('affected_emails'):
|
| 100 |
+
# We're in preview mode, show preview data
|
| 101 |
+
return {
|
| 102 |
+
'emails': preview_data['affected_emails'],
|
| 103 |
+
'is_preview': True
|
| 104 |
+
}
|
| 105 |
+
else:
|
| 106 |
+
# Normal mode, get from backend
|
| 107 |
+
try:
|
| 108 |
+
response = mcp_client.list_emails(folder=folder)
|
| 109 |
+
return {
|
| 110 |
+
'emails': response.get('emails', []),
|
| 111 |
+
'is_preview': False
|
| 112 |
+
}
|
| 113 |
+
except:
|
| 114 |
+
return {
|
| 115 |
+
'emails': [],
|
| 116 |
+
'is_preview': False
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def create_email_display_from_data(email_data: Dict[str, Any]) -> Tuple[List[str], str]:
|
| 121 |
+
"""
|
| 122 |
+
Create email display from data returned by backend
|
| 123 |
+
Returns: (folder_choices, email_html)
|
| 124 |
+
"""
|
| 125 |
+
from .ui_utils import get_folder_dropdown_choices, create_email_html
|
| 126 |
+
|
| 127 |
+
emails = email_data.get('emails', [])
|
| 128 |
+
|
| 129 |
+
# Get folder choices
|
| 130 |
+
folder_choices = get_folder_dropdown_choices(emails)
|
| 131 |
+
if not folder_choices:
|
| 132 |
+
folder_choices = ["Inbox (0)"]
|
| 133 |
+
|
| 134 |
+
# Create HTML
|
| 135 |
+
html = create_email_html(emails)
|
| 136 |
+
|
| 137 |
+
return folder_choices, html
|
archive/ui_old/archive/ui_streaming.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Streaming and tool visibility improvements for the chat interface
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from typing import Generator, Dict, Any, List, Tuple
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def process_chat_message_streaming(
|
| 10 |
+
user_message: str,
|
| 11 |
+
chat_history: List[Dict[str, str]],
|
| 12 |
+
mcp_client: Any,
|
| 13 |
+
current_emails: List[Dict[str, Any]],
|
| 14 |
+
pending_rules: List[Dict[str, Any]],
|
| 15 |
+
applied_rules: List[Dict[str, Any]],
|
| 16 |
+
rule_counter: int
|
| 17 |
+
) -> Generator[Tuple, None, None]:
|
| 18 |
+
"""Process chat messages with streaming responses and tool visibility"""
|
| 19 |
+
|
| 20 |
+
from .simple_agent import process_chat_message as process_agent_message
|
| 21 |
+
from .ui_utils import create_interactive_rule_cards
|
| 22 |
+
import os
|
| 23 |
+
|
| 24 |
+
if not user_message.strip():
|
| 25 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 26 |
+
"Ready...", pending_rules, rule_counter)
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
# Check if API key is available
|
| 30 |
+
if not os.getenv('OPENROUTER_API_KEY'):
|
| 31 |
+
chat_history.append({"role": "user", "content": user_message})
|
| 32 |
+
error_msg = "⚠️ OpenRouter API key not configured. Please set OPENROUTER_API_KEY in HuggingFace Spaces secrets to enable AI features."
|
| 33 |
+
chat_history.append({"role": "assistant", "content": error_msg})
|
| 34 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 35 |
+
"API key missing", pending_rules, rule_counter)
|
| 36 |
+
return
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
# Add user message to history
|
| 40 |
+
chat_history.append({"role": "user", "content": user_message})
|
| 41 |
+
|
| 42 |
+
# Show initial thinking/processing message
|
| 43 |
+
thinking_message = {"role": "assistant", "content": "🤔 Thinking..."}
|
| 44 |
+
temp_history = chat_history + [thinking_message]
|
| 45 |
+
yield (temp_history, "", create_interactive_rule_cards(pending_rules),
|
| 46 |
+
"Processing...", pending_rules, rule_counter)
|
| 47 |
+
|
| 48 |
+
# Check if we should analyze emails
|
| 49 |
+
analyze_keywords = ['analyze', 'suggest', 'organize', 'rules', 'help me organize',
|
| 50 |
+
'create rules', 'inbox', 'patterns']
|
| 51 |
+
should_analyze = any(keyword in user_message.lower() for keyword in analyze_keywords)
|
| 52 |
+
|
| 53 |
+
if should_analyze and current_emails:
|
| 54 |
+
# Show analyzing status
|
| 55 |
+
analyzing_msg = {"role": "assistant", "content": "📊 Analyzing your emails...\n\n*Looking for patterns in:*\n- Senders and domains\n- Subject lines\n- Email types (newsletters, work, personal)\n- Frequency patterns"}
|
| 56 |
+
temp_history = chat_history + [analyzing_msg]
|
| 57 |
+
yield (temp_history, "", create_interactive_rule_cards(pending_rules),
|
| 58 |
+
f"Analyzing {len(current_emails)} emails...", pending_rules, rule_counter)
|
| 59 |
+
|
| 60 |
+
# Small delay for visual feedback
|
| 61 |
+
time.sleep(0.5)
|
| 62 |
+
|
| 63 |
+
# Get current rule state
|
| 64 |
+
rule_state = {
|
| 65 |
+
'proposedRules': [r for r in pending_rules if r['status'] == 'pending'],
|
| 66 |
+
'activeRules': applied_rules,
|
| 67 |
+
'rejectedRules': [r for r in pending_rules if r['status'] == 'rejected']
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# Process with agent (this will use internal streaming)
|
| 71 |
+
response_data = process_agent_message(
|
| 72 |
+
user_message=user_message,
|
| 73 |
+
emails=current_emails,
|
| 74 |
+
conversation_history=chat_history[:-1], # Don't include the message we just added
|
| 75 |
+
rule_state=rule_state
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Extract response and rules
|
| 79 |
+
response_text = response_data.get('response', '')
|
| 80 |
+
extracted_rules = response_data.get('rules', [])
|
| 81 |
+
|
| 82 |
+
# Update rules if any were extracted
|
| 83 |
+
updated_pending_rules = pending_rules.copy()
|
| 84 |
+
for rule in extracted_rules:
|
| 85 |
+
if not any(r.get('rule_id') == rule.get('rule_id') for r in updated_pending_rules):
|
| 86 |
+
rule['status'] = 'pending'
|
| 87 |
+
rule['id'] = rule.get('rule_id', f'rule_{rule_counter}')
|
| 88 |
+
updated_pending_rules.append(rule)
|
| 89 |
+
rule_counter += 1
|
| 90 |
+
|
| 91 |
+
# Show the response with streaming effect (simulate character-by-character)
|
| 92 |
+
assistant_msg = {"role": "assistant", "content": ""}
|
| 93 |
+
temp_history = chat_history + [assistant_msg]
|
| 94 |
+
|
| 95 |
+
# Stream the response in chunks for better UX
|
| 96 |
+
chunk_size = 50 # Characters per chunk
|
| 97 |
+
for i in range(0, len(response_text), chunk_size):
|
| 98 |
+
assistant_msg["content"] = response_text[:i+chunk_size]
|
| 99 |
+
yield (temp_history, "", create_interactive_rule_cards(updated_pending_rules),
|
| 100 |
+
"Typing...", updated_pending_rules, rule_counter)
|
| 101 |
+
time.sleep(0.02) # Small delay for streaming effect
|
| 102 |
+
|
| 103 |
+
# Final update with complete message
|
| 104 |
+
chat_history.append({"role": "assistant", "content": response_text})
|
| 105 |
+
|
| 106 |
+
# Show completion status
|
| 107 |
+
if extracted_rules:
|
| 108 |
+
status = f"✅ Found {len(extracted_rules)} new rules"
|
| 109 |
+
else:
|
| 110 |
+
status = "Processing complete"
|
| 111 |
+
|
| 112 |
+
yield (chat_history, "", create_interactive_rule_cards(updated_pending_rules),
|
| 113 |
+
status, updated_pending_rules, rule_counter)
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
error_msg = f"I encountered an error: {str(e)}. Please try again."
|
| 117 |
+
chat_history.append({"role": "assistant", "content": error_msg})
|
| 118 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 119 |
+
f"Error: {str(e)}", pending_rules, rule_counter)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def create_tool_status_message(tool_name: str, status: str = "running") -> str:
|
| 123 |
+
"""Create a formatted tool status message"""
|
| 124 |
+
icons = {
|
| 125 |
+
"analyzing_emails": "📊",
|
| 126 |
+
"extracting_patterns": "🔍",
|
| 127 |
+
"creating_rules": "📋",
|
| 128 |
+
"complete": "✅"
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
messages = {
|
| 132 |
+
"analyzing_emails": "Analyzing email patterns...",
|
| 133 |
+
"extracting_patterns": "Extracting common patterns...",
|
| 134 |
+
"creating_rules": "Creating organization rules...",
|
| 135 |
+
"complete": "Analysis complete!"
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
icon = icons.get(tool_name, "🔧")
|
| 139 |
+
message = messages.get(tool_name, f"Running {tool_name}...")
|
| 140 |
+
|
| 141 |
+
if status == "running":
|
| 142 |
+
return f"{icon} {message}"
|
| 143 |
+
else:
|
| 144 |
+
return f"{icon} {message} ✓"
|
archive/ui_old/archive/ui_streaming_fixed.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fixed streaming implementation that works with Gradio's expectations
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from typing import Generator, List, Dict, Any, Tuple
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def process_chat_streaming(
|
| 11 |
+
user_message: str,
|
| 12 |
+
chat_history: List[Dict[str, str]],
|
| 13 |
+
mcp_client: Any,
|
| 14 |
+
current_emails: List[Dict[str, Any]],
|
| 15 |
+
pending_rules: List[Dict[str, Any]],
|
| 16 |
+
applied_rules: List[Dict[str, Any]],
|
| 17 |
+
rule_counter: int
|
| 18 |
+
) -> Generator[Tuple, None, None]:
|
| 19 |
+
"""Streaming chat handler that yields updates progressively"""
|
| 20 |
+
|
| 21 |
+
from .simple_agent import process_chat_message as process_agent_message
|
| 22 |
+
from .ui_utils import create_interactive_rule_cards
|
| 23 |
+
from .ui_chat import format_tool_message
|
| 24 |
+
|
| 25 |
+
if not user_message.strip():
|
| 26 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 27 |
+
"Ready...", pending_rules, rule_counter)
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
# Check API key
|
| 31 |
+
if not os.getenv('OPENROUTER_API_KEY'):
|
| 32 |
+
chat_history = chat_history + [{"role": "user", "content": user_message}]
|
| 33 |
+
error_msg = "⚠️ OpenRouter API key not configured. Please set OPENROUTER_API_KEY."
|
| 34 |
+
chat_history = chat_history + [{"role": "assistant", "content": error_msg}]
|
| 35 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 36 |
+
"API key missing", pending_rules, rule_counter)
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
# Add user message
|
| 41 |
+
chat_history = chat_history + [{"role": "user", "content": user_message}]
|
| 42 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 43 |
+
"Processing...", pending_rules, rule_counter)
|
| 44 |
+
|
| 45 |
+
# Check if analyzing
|
| 46 |
+
analyze_keywords = ['analyze', 'suggest', 'organize', 'rules', 'inbox']
|
| 47 |
+
should_analyze = any(keyword in user_message.lower() for keyword in analyze_keywords)
|
| 48 |
+
|
| 49 |
+
if should_analyze and current_emails:
|
| 50 |
+
# Show analyzing tool message
|
| 51 |
+
analysis_details = f"""Analyzing {len(current_emails)} emails...
|
| 52 |
+
|
| 53 |
+
Looking for patterns in:
|
| 54 |
+
• Sender domains and addresses
|
| 55 |
+
• Subject line keywords
|
| 56 |
+
• Email frequency by sender
|
| 57 |
+
• Common phrases and topics
|
| 58 |
+
|
| 59 |
+
Email categories found:
|
| 60 |
+
• Newsletters: {len([e for e in current_emails if 'newsletter' in e.get('from_email', '').lower()])}
|
| 61 |
+
• Work emails: {len([e for e in current_emails if '@company.com' in e.get('from_email', '')])}
|
| 62 |
+
• Personal: {len([e for e in current_emails if not any(kw in e.get('from_email', '').lower() for kw in ['newsletter', 'noreply', 'promo'])])}"""
|
| 63 |
+
|
| 64 |
+
tool_html = format_tool_message("analyzing_emails", analysis_details, "complete")
|
| 65 |
+
chat_history = chat_history + [{"role": "assistant", "content": tool_html}]
|
| 66 |
+
|
| 67 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 68 |
+
"Analyzing emails...", pending_rules, rule_counter)
|
| 69 |
+
|
| 70 |
+
# Small pause for effect
|
| 71 |
+
time.sleep(0.5)
|
| 72 |
+
|
| 73 |
+
# Get rule state
|
| 74 |
+
rule_state = {
|
| 75 |
+
'proposedRules': [r for r in pending_rules if r['status'] == 'pending'],
|
| 76 |
+
'activeRules': applied_rules,
|
| 77 |
+
'rejectedRules': [r for r in pending_rules if r['status'] == 'rejected']
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
# For simple messages, use non-streaming
|
| 81 |
+
if not should_analyze:
|
| 82 |
+
response_data = process_agent_message(
|
| 83 |
+
user_message=user_message,
|
| 84 |
+
emails=current_emails,
|
| 85 |
+
conversation_history=[m for m in chat_history[:-1]],
|
| 86 |
+
rule_state=rule_state
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
response_text = response_data.get('response', '')
|
| 90 |
+
extracted_rules = response_data.get('rules', [])
|
| 91 |
+
else:
|
| 92 |
+
# For analysis, use streaming
|
| 93 |
+
from .agent_streaming import analyze_emails_streaming
|
| 94 |
+
|
| 95 |
+
# Start streaming analysis
|
| 96 |
+
streaming_msg = {"role": "assistant", "content": ""}
|
| 97 |
+
temp_history = chat_history + [streaming_msg]
|
| 98 |
+
|
| 99 |
+
response_text = ""
|
| 100 |
+
extracted_rules = []
|
| 101 |
+
|
| 102 |
+
# Stream the analysis
|
| 103 |
+
for output in analyze_emails_streaming(current_emails):
|
| 104 |
+
if output['type'] == 'token':
|
| 105 |
+
response_text = output['full_response']
|
| 106 |
+
streaming_msg["content"] = response_text
|
| 107 |
+
yield (temp_history, "", create_interactive_rule_cards(pending_rules),
|
| 108 |
+
"Analyzing...", pending_rules, rule_counter)
|
| 109 |
+
elif output['type'] == 'rules':
|
| 110 |
+
extracted_rules = output['rules']
|
| 111 |
+
response_text = output['full_response']
|
| 112 |
+
elif output['type'] == 'error':
|
| 113 |
+
response_text = f"Error: {output['content']}"
|
| 114 |
+
break
|
| 115 |
+
|
| 116 |
+
# Show rule creation if needed
|
| 117 |
+
if extracted_rules:
|
| 118 |
+
rules_details = f"""Created {len(extracted_rules)} rules:
|
| 119 |
+
|
| 120 |
+
"""
|
| 121 |
+
for rule in extracted_rules:
|
| 122 |
+
rules_details += f"📋 {rule['name']}\n"
|
| 123 |
+
rules_details += f" {rule['description']}\n"
|
| 124 |
+
rules_details += f" Confidence: {rule.get('confidence', 0.8)*100:.0f}%\n\n"
|
| 125 |
+
|
| 126 |
+
tool_html = format_tool_message("creating_rules", rules_details, "complete")
|
| 127 |
+
chat_history = chat_history + [{"role": "assistant", "content": tool_html}]
|
| 128 |
+
|
| 129 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 130 |
+
"Creating rules...", pending_rules, rule_counter)
|
| 131 |
+
|
| 132 |
+
time.sleep(0.3)
|
| 133 |
+
|
| 134 |
+
# Update rules
|
| 135 |
+
updated_pending_rules = pending_rules.copy()
|
| 136 |
+
for rule in extracted_rules:
|
| 137 |
+
if not any(r.get('rule_id') == rule.get('rule_id') for r in updated_pending_rules):
|
| 138 |
+
rule['status'] = 'pending'
|
| 139 |
+
rule['id'] = rule.get('rule_id', f'rule_{rule_counter}')
|
| 140 |
+
updated_pending_rules.append(rule)
|
| 141 |
+
rule_counter += 1
|
| 142 |
+
|
| 143 |
+
# Clean response
|
| 144 |
+
clean_response = response_text
|
| 145 |
+
if "<!-- RULES_JSON_START" in clean_response:
|
| 146 |
+
clean_response = clean_response.split("<!-- RULES_JSON_START")[0].strip()
|
| 147 |
+
|
| 148 |
+
# Only do character streaming for non-analyzed messages
|
| 149 |
+
if not should_analyze:
|
| 150 |
+
streaming_msg = {"role": "assistant", "content": ""}
|
| 151 |
+
temp_history = chat_history + [streaming_msg]
|
| 152 |
+
|
| 153 |
+
# Stream in chunks
|
| 154 |
+
chunk_size = 5 # Characters per update
|
| 155 |
+
for i in range(0, len(clean_response), chunk_size):
|
| 156 |
+
streaming_msg["content"] = clean_response[:i+chunk_size]
|
| 157 |
+
yield (temp_history, "", create_interactive_rule_cards(updated_pending_rules),
|
| 158 |
+
"Typing...", updated_pending_rules, rule_counter)
|
| 159 |
+
time.sleep(0.01) # Fast streaming
|
| 160 |
+
|
| 161 |
+
# Final message
|
| 162 |
+
chat_history = chat_history + [{"role": "assistant", "content": clean_response}]
|
| 163 |
+
|
| 164 |
+
status = f"✅ Found {len(extracted_rules)} rules" if extracted_rules else "Complete"
|
| 165 |
+
yield (chat_history, "", create_interactive_rule_cards(updated_pending_rules),
|
| 166 |
+
status, updated_pending_rules, rule_counter)
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
error_msg = f"Error: {str(e)}"
|
| 170 |
+
chat_history = chat_history + [{"role": "assistant", "content": error_msg}]
|
| 171 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 172 |
+
error_msg, pending_rules, rule_counter)
|
archive/ui_old/handlers/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI event handlers
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from .preview import handle_preview_rule
|
| 6 |
+
|
| 7 |
+
__all__ = ['handle_preview_rule']
|
archive/ui_old/handlers/preview.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Preview handler that properly delegates to MCP backend
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
from typing import List, Dict, Any, Tuple
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def handle_preview_rule(
|
| 10 |
+
rule_id: str,
|
| 11 |
+
sort_option: str,
|
| 12 |
+
search_query: str,
|
| 13 |
+
mcp_client: Any,
|
| 14 |
+
pending_rules: List[Dict[str, Any]],
|
| 15 |
+
current_emails: List[Dict[str, Any]],
|
| 16 |
+
sample_emails: List[Dict[str, Any]],
|
| 17 |
+
preview_emails: List[Dict[str, Any]]
|
| 18 |
+
) -> Tuple:
|
| 19 |
+
"""
|
| 20 |
+
Handle rule preview by delegating to MCP backend
|
| 21 |
+
|
| 22 |
+
Returns all 7 outputs expected by the UI:
|
| 23 |
+
1. folder_dropdown update
|
| 24 |
+
2. email_display HTML
|
| 25 |
+
3. rule_cards HTML
|
| 26 |
+
4. preview_banner HTML
|
| 27 |
+
5. status_msg
|
| 28 |
+
6. pending_rules state
|
| 29 |
+
7. current_emails state
|
| 30 |
+
"""
|
| 31 |
+
from ..utils.display import create_interactive_rule_cards, create_preview_banner, create_email_html
|
| 32 |
+
from ..utils.helpers import get_preview_rules_count, get_folder_dropdown_choices, filter_emails
|
| 33 |
+
|
| 34 |
+
# Validate input
|
| 35 |
+
if not rule_id or rule_id.strip() == "":
|
| 36 |
+
return (
|
| 37 |
+
gr.update(), # No dropdown change
|
| 38 |
+
create_email_html(current_emails),
|
| 39 |
+
create_interactive_rule_cards(pending_rules),
|
| 40 |
+
create_preview_banner(0),
|
| 41 |
+
"❌ Error: No rule ID provided",
|
| 42 |
+
pending_rules,
|
| 43 |
+
current_emails
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Find the rule
|
| 47 |
+
rule = next((r for r in pending_rules if r["id"] == rule_id), None)
|
| 48 |
+
if not rule:
|
| 49 |
+
return (
|
| 50 |
+
gr.update(),
|
| 51 |
+
create_email_html(current_emails),
|
| 52 |
+
create_interactive_rule_cards(pending_rules),
|
| 53 |
+
create_preview_banner(0),
|
| 54 |
+
f"❌ Rule not found: {rule_id}",
|
| 55 |
+
pending_rules,
|
| 56 |
+
current_emails
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
# Call MCP backend to get preview
|
| 61 |
+
preview_response = mcp_client.preview_rule(rule)
|
| 62 |
+
|
| 63 |
+
if preview_response.get('success', False):
|
| 64 |
+
# Mark rule as in preview
|
| 65 |
+
rule["status"] = "preview"
|
| 66 |
+
|
| 67 |
+
# Get the affected emails from backend
|
| 68 |
+
affected_emails = preview_response.get('affected_emails', [])
|
| 69 |
+
|
| 70 |
+
# If we got emails, use them for display
|
| 71 |
+
if affected_emails:
|
| 72 |
+
display_emails = affected_emails
|
| 73 |
+
else:
|
| 74 |
+
# Fallback to current emails
|
| 75 |
+
display_emails = current_emails
|
| 76 |
+
|
| 77 |
+
# Get folder choices from the emails we're displaying
|
| 78 |
+
folder_choices = get_folder_dropdown_choices(display_emails)
|
| 79 |
+
if not folder_choices:
|
| 80 |
+
folder_choices = ["Inbox (0)"]
|
| 81 |
+
|
| 82 |
+
# Filter and create display
|
| 83 |
+
folder_value = folder_choices[0]
|
| 84 |
+
filtered = filter_emails(folder_value, search_query, sort_option, display_emails)
|
| 85 |
+
email_html = create_email_html(filtered, folder_value)
|
| 86 |
+
|
| 87 |
+
# Create status message
|
| 88 |
+
stats = preview_response.get('statistics', {})
|
| 89 |
+
status_msg = f"👁️ Preview: {stats.get('matched_count', 0)} emails would be affected"
|
| 90 |
+
|
| 91 |
+
return (
|
| 92 |
+
gr.update(choices=folder_choices, value=folder_value),
|
| 93 |
+
email_html,
|
| 94 |
+
create_interactive_rule_cards(pending_rules),
|
| 95 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 96 |
+
status_msg,
|
| 97 |
+
pending_rules,
|
| 98 |
+
display_emails # Update current emails to preview data
|
| 99 |
+
)
|
| 100 |
+
else:
|
| 101 |
+
# Backend preview failed
|
| 102 |
+
return (
|
| 103 |
+
gr.update(),
|
| 104 |
+
create_email_html(current_emails),
|
| 105 |
+
create_interactive_rule_cards(pending_rules),
|
| 106 |
+
create_preview_banner(0),
|
| 107 |
+
"❌ Preview failed - backend error",
|
| 108 |
+
pending_rules,
|
| 109 |
+
current_emails
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"Error in preview handler: {e}")
|
| 114 |
+
# Return safe defaults on error
|
| 115 |
+
return (
|
| 116 |
+
gr.update(),
|
| 117 |
+
create_email_html(current_emails),
|
| 118 |
+
create_interactive_rule_cards(pending_rules),
|
| 119 |
+
create_preview_banner(0),
|
| 120 |
+
f"❌ Error: {str(e)}",
|
| 121 |
+
pending_rules,
|
| 122 |
+
current_emails
|
| 123 |
+
)
|
archive/ui_old/main.py
ADDED
|
@@ -0,0 +1,769 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI Components for Email Rule Agent - Refactored with proper session management
|
| 3 |
+
Self-contained implementation with all necessary functions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import List, Dict, Any, Tuple
|
| 12 |
+
|
| 13 |
+
# Import UI utility functions
|
| 14 |
+
from .ui.utils import (
|
| 15 |
+
create_email_html,
|
| 16 |
+
create_interactive_rule_cards,
|
| 17 |
+
create_preview_banner,
|
| 18 |
+
filter_emails,
|
| 19 |
+
get_folder_dropdown_choices,
|
| 20 |
+
get_preview_rules_count
|
| 21 |
+
)
|
| 22 |
+
from .ui.utils.display import (
|
| 23 |
+
load_email_data,
|
| 24 |
+
get_folder_counts,
|
| 25 |
+
extract_folder_name,
|
| 26 |
+
format_date,
|
| 27 |
+
sort_emails
|
| 28 |
+
)
|
| 29 |
+
from .ui.handlers import handle_preview_rule
|
| 30 |
+
|
| 31 |
+
# Data paths
|
| 32 |
+
DATA_PATH = "./data/emails.json"
|
| 33 |
+
PREVIEW_DATA_PATH = "./data/preview_emails.json"
|
| 34 |
+
RULES_DATA_PATH = "./data/rules.json"
|
| 35 |
+
|
| 36 |
+
# Import agent
|
| 37 |
+
from .simple_agent import process_chat_message as process_agent_message
|
| 38 |
+
from .ui_streaming import process_chat_message_streaming
|
| 39 |
+
from .ui_chat import process_chat_with_tools
|
| 40 |
+
from .ui_streaming_fixed import process_chat_streaming
|
| 41 |
+
|
| 42 |
+
# Import MCP client
|
| 43 |
+
from .mcp_client import MCPClient
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def load_rules_from_json(file_path=RULES_DATA_PATH):
|
| 47 |
+
"""
|
| 48 |
+
Load rules from JSON file and convert them to the internal format.
|
| 49 |
+
Returns tuple of (loaded_rules, rule_counter)
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
if not os.path.exists(file_path):
|
| 53 |
+
print(f"Warning: {file_path} not found.")
|
| 54 |
+
return [], 0
|
| 55 |
+
|
| 56 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 57 |
+
rules_data = json.load(f)
|
| 58 |
+
|
| 59 |
+
loaded_rules = []
|
| 60 |
+
rule_counter = 0
|
| 61 |
+
|
| 62 |
+
for rule_data in rules_data.get("rules", []):
|
| 63 |
+
conditions = []
|
| 64 |
+
for condition in rule_data.get("conditions", []):
|
| 65 |
+
condition_text = f"{condition.get('type', 'field')} {condition.get('operator', 'contains')} '{condition.get('value', '')}'"
|
| 66 |
+
conditions.append(condition_text)
|
| 67 |
+
|
| 68 |
+
actions = []
|
| 69 |
+
for action in rule_data.get("actions", []):
|
| 70 |
+
action_text = f"{action.get('operation', 'apply')} {action.get('type', 'action')} '{action.get('value', '')}'"
|
| 71 |
+
actions.append(action_text)
|
| 72 |
+
|
| 73 |
+
rule = {
|
| 74 |
+
"id": rule_data.get("rule_id", f"rule_{rule_counter}"),
|
| 75 |
+
"name": rule_data.get("name", "Unnamed Rule"),
|
| 76 |
+
"description": rule_data.get("description", "No description available"),
|
| 77 |
+
"conditions": conditions,
|
| 78 |
+
"actions": actions,
|
| 79 |
+
"impact": rule_data.get("impact", "This rule will process emails"),
|
| 80 |
+
"status": "pending",
|
| 81 |
+
"keywords": rule_data.get("keywords", []),
|
| 82 |
+
"created_at": rule_data.get("created_at", datetime.now().isoformat())
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
loaded_rules.append(rule)
|
| 86 |
+
rule_counter += 1
|
| 87 |
+
|
| 88 |
+
print(f"Successfully loaded {len(loaded_rules)} rules from {file_path}")
|
| 89 |
+
return loaded_rules, rule_counter
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
print(f"Error loading rules from {file_path}: {e}")
|
| 93 |
+
return [], 0
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def preview_rule_with_mcp(rule_id, sort_option, search_query, mcp_client,
|
| 97 |
+
pending_rules, current_emails, sample_emails, preview_emails):
|
| 98 |
+
"""Handle rule preview using MCP client - properly returns all expected outputs"""
|
| 99 |
+
|
| 100 |
+
if not rule_id or rule_id.strip() == "":
|
| 101 |
+
# Return all 7 outputs as expected by the click handler
|
| 102 |
+
return (gr.update(), # folder_dropdown
|
| 103 |
+
create_email_html(current_emails), # email_display
|
| 104 |
+
create_interactive_rule_cards(pending_rules), # rule_cards
|
| 105 |
+
create_preview_banner(get_preview_rules_count(pending_rules)), # preview_banner
|
| 106 |
+
"❌ Error: No rule ID provided", # status_msg
|
| 107 |
+
pending_rules, # pending_rules_state
|
| 108 |
+
current_emails) # current_emails_state
|
| 109 |
+
|
| 110 |
+
rule = next((r for r in pending_rules if r["id"] == rule_id), None)
|
| 111 |
+
if rule:
|
| 112 |
+
try:
|
| 113 |
+
# Call Modal backend to preview rule
|
| 114 |
+
preview_response = mcp_client.preview_rule(rule)
|
| 115 |
+
|
| 116 |
+
if preview_response.get('success', False):
|
| 117 |
+
# Update local preview emails with the result
|
| 118 |
+
preview_emails = preview_response.get('affected_emails', preview_emails)
|
| 119 |
+
current_emails = preview_emails
|
| 120 |
+
|
| 121 |
+
rule["status"] = "preview"
|
| 122 |
+
print(f"Previewing rule: {rule['name']} (ID: {rule_id})")
|
| 123 |
+
|
| 124 |
+
new_folder_choices = get_folder_dropdown_choices(current_emails)
|
| 125 |
+
new_folder_value = new_folder_choices[0] if new_folder_choices else "Inbox (0)"
|
| 126 |
+
filtered_emails = filter_emails(new_folder_value, search_query, sort_option, current_emails)
|
| 127 |
+
new_email_display = create_email_html(filtered_emails, new_folder_value)
|
| 128 |
+
|
| 129 |
+
stats = preview_response.get('statistics', {})
|
| 130 |
+
status_msg = f"👁️ Preview: {stats.get('matched_count', 0)} emails would be affected"
|
| 131 |
+
|
| 132 |
+
return (gr.update(choices=new_folder_choices, value=new_folder_value),
|
| 133 |
+
new_email_display,
|
| 134 |
+
create_interactive_rule_cards(pending_rules),
|
| 135 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 136 |
+
status_msg,
|
| 137 |
+
pending_rules, current_emails)
|
| 138 |
+
else:
|
| 139 |
+
# Fallback to local preview
|
| 140 |
+
return preview_rule(rule_id, sort_option, search_query, pending_rules, current_emails, sample_emails, preview_emails)
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
print(f"Error calling MCP preview: {e}")
|
| 144 |
+
# Fallback to local preview
|
| 145 |
+
return preview_rule(rule_id, sort_option, search_query, pending_rules, current_emails, sample_emails, preview_emails)
|
| 146 |
+
|
| 147 |
+
# Return in correct order for outputs
|
| 148 |
+
return (gr.update(), # folder_dropdown - no change
|
| 149 |
+
create_email_html(current_emails), # email_display
|
| 150 |
+
create_interactive_rule_cards(pending_rules), # rule_cards
|
| 151 |
+
create_preview_banner(get_preview_rules_count(pending_rules)), # preview_banner
|
| 152 |
+
f"❌ Rule not found: {rule_id}", # status_msg
|
| 153 |
+
pending_rules, current_emails) # states
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def preview_rule(rule_id, sort_option, search_query, pending_rules, current_emails, sample_emails, preview_emails):
|
| 157 |
+
"""Handle rule preview - set status to preview and switch to preview emails (fallback)"""
|
| 158 |
+
|
| 159 |
+
if not rule_id or rule_id.strip() == "":
|
| 160 |
+
# Return in the correct order matching the outputs
|
| 161 |
+
return (gr.update(), # folder_dropdown - no change
|
| 162 |
+
create_email_html(current_emails), # email_display
|
| 163 |
+
create_interactive_rule_cards(pending_rules), # rule_cards
|
| 164 |
+
create_preview_banner(get_preview_rules_count(pending_rules)), # preview_banner
|
| 165 |
+
"❌ Error: No rule ID provided", # status_msg
|
| 166 |
+
pending_rules, current_emails) # states
|
| 167 |
+
|
| 168 |
+
rule = next((r for r in pending_rules if r["id"] == rule_id), None)
|
| 169 |
+
if rule:
|
| 170 |
+
rule["status"] = "preview"
|
| 171 |
+
print(f"Previewing rule: {rule['name']} (ID: {rule_id})")
|
| 172 |
+
|
| 173 |
+
# Switch to preview emails - ensure they have data
|
| 174 |
+
if preview_emails:
|
| 175 |
+
current_emails = preview_emails
|
| 176 |
+
new_folder_choices = get_folder_dropdown_choices(current_emails)
|
| 177 |
+
# If no folders found, ensure we have at least Inbox
|
| 178 |
+
if not new_folder_choices:
|
| 179 |
+
new_folder_choices = ["Inbox (0)"]
|
| 180 |
+
new_folder_value = new_folder_choices[0]
|
| 181 |
+
filtered_emails = filter_emails(new_folder_value, search_query, sort_option, current_emails)
|
| 182 |
+
new_email_display = create_email_html(filtered_emails, new_folder_value)
|
| 183 |
+
|
| 184 |
+
return (gr.update(choices=new_folder_choices, value=new_folder_value),
|
| 185 |
+
new_email_display,
|
| 186 |
+
create_interactive_rule_cards(pending_rules),
|
| 187 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 188 |
+
f"👁️ Previewing rule: {rule['name']}",
|
| 189 |
+
pending_rules, current_emails)
|
| 190 |
+
|
| 191 |
+
# Return in correct order for outputs
|
| 192 |
+
return (gr.update(), # folder_dropdown - no change
|
| 193 |
+
create_email_html(current_emails), # email_display
|
| 194 |
+
create_interactive_rule_cards(pending_rules), # rule_cards
|
| 195 |
+
create_preview_banner(get_preview_rules_count(pending_rules)), # preview_banner
|
| 196 |
+
f"❌ Rule not found: {rule_id}", # status_msg
|
| 197 |
+
pending_rules, current_emails) # states
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def accept_rule_with_mcp(rule_id, mcp_client, current_folder, sort_option, search_query,
|
| 201 |
+
pending_rules, applied_rules, current_emails, sample_emails):
|
| 202 |
+
"""Handle rule acceptance using MCP client and update email display"""
|
| 203 |
+
|
| 204 |
+
if not rule_id or rule_id.strip() == "":
|
| 205 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 206 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 207 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 208 |
+
"❌ Error: No rule ID provided",
|
| 209 |
+
create_email_html(filtered_emails, current_folder),
|
| 210 |
+
gr.update(choices=get_folder_dropdown_choices(current_emails)),
|
| 211 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 212 |
+
|
| 213 |
+
for rule in pending_rules:
|
| 214 |
+
if rule["id"] == rule_id:
|
| 215 |
+
try:
|
| 216 |
+
# Apply rule via MCP client
|
| 217 |
+
apply_response = mcp_client.apply_rule(rule, preview=False)
|
| 218 |
+
|
| 219 |
+
if apply_response.get('success', False):
|
| 220 |
+
# Save rule to backend
|
| 221 |
+
mcp_client.save_rule(rule)
|
| 222 |
+
|
| 223 |
+
rule["status"] = "accepted"
|
| 224 |
+
applied_rules.append(rule.copy())
|
| 225 |
+
|
| 226 |
+
# Update emails if provided
|
| 227 |
+
if 'updated_emails' in apply_response:
|
| 228 |
+
sample_emails = apply_response['updated_emails']
|
| 229 |
+
current_emails = sample_emails
|
| 230 |
+
|
| 231 |
+
# Exit preview mode if we were in it
|
| 232 |
+
for r in pending_rules:
|
| 233 |
+
if r["status"] == "preview":
|
| 234 |
+
r["status"] = "pending"
|
| 235 |
+
|
| 236 |
+
stats = apply_response.get('statistics', {})
|
| 237 |
+
status_msg = f"✅ Rule applied: {stats.get('processed_count', 0)} emails processed"
|
| 238 |
+
|
| 239 |
+
# Update email display with new data
|
| 240 |
+
new_folder_choices = get_folder_dropdown_choices(current_emails)
|
| 241 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 242 |
+
|
| 243 |
+
print(f"Rule accepted and applied: {rule['name']} (ID: {rule_id})")
|
| 244 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 245 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 246 |
+
status_msg,
|
| 247 |
+
create_email_html(filtered_emails, current_folder),
|
| 248 |
+
gr.update(choices=new_folder_choices),
|
| 249 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 250 |
+
else:
|
| 251 |
+
# Fallback to local acceptance
|
| 252 |
+
result = accept_rule(rule_id, pending_rules, applied_rules)
|
| 253 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 254 |
+
return result + (create_email_html(filtered_emails, current_folder),
|
| 255 |
+
gr.update(),
|
| 256 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
print(f"Error calling MCP apply: {e}")
|
| 260 |
+
# Fallback to local acceptance
|
| 261 |
+
result = accept_rule(rule_id, pending_rules, applied_rules)
|
| 262 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 263 |
+
return result + (create_email_html(filtered_emails, current_folder),
|
| 264 |
+
gr.update(),
|
| 265 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 266 |
+
|
| 267 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 268 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 269 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 270 |
+
f"❌ Rule not found: {rule_id}",
|
| 271 |
+
create_email_html(filtered_emails, current_folder),
|
| 272 |
+
gr.update(),
|
| 273 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def accept_rule(rule_id, pending_rules, applied_rules):
|
| 277 |
+
"""Handle rule acceptance (fallback)"""
|
| 278 |
+
|
| 279 |
+
if not rule_id or rule_id.strip() == "":
|
| 280 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 281 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 282 |
+
"❌ Error: No rule ID provided")
|
| 283 |
+
|
| 284 |
+
for rule in pending_rules:
|
| 285 |
+
if rule["id"] == rule_id:
|
| 286 |
+
rule["status"] = "accepted"
|
| 287 |
+
applied_rules.append(rule.copy())
|
| 288 |
+
print(f"Rule accepted: {rule['name']} (ID: {rule_id})")
|
| 289 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 290 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 291 |
+
f"✅ Rule accepted: {rule['name']}")
|
| 292 |
+
|
| 293 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 294 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 295 |
+
f"❌ Rule not found: {rule_id}")
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def reject_rule(rule_id, pending_rules):
|
| 299 |
+
"""Handle rule rejection"""
|
| 300 |
+
|
| 301 |
+
if not rule_id or rule_id.strip() == "":
|
| 302 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 303 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 304 |
+
"❌ Error: No rule ID provided",
|
| 305 |
+
pending_rules)
|
| 306 |
+
|
| 307 |
+
for rule in pending_rules:
|
| 308 |
+
if rule["id"] == rule_id:
|
| 309 |
+
rule["status"] = "rejected"
|
| 310 |
+
print(f"Rule rejected: {rule['name']} (ID: {rule_id})")
|
| 311 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 312 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 313 |
+
f"❌ Rule rejected: {rule['name']}",
|
| 314 |
+
pending_rules)
|
| 315 |
+
|
| 316 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 317 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 318 |
+
f"❌ Rule not found: {rule_id}",
|
| 319 |
+
pending_rules)
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def exit_preview_mode(sort_option, search_query, pending_rules, current_emails, sample_emails):
|
| 323 |
+
"""Exit preview mode by setting all preview rules back to pending"""
|
| 324 |
+
|
| 325 |
+
for rule in pending_rules:
|
| 326 |
+
if rule["status"] == "preview":
|
| 327 |
+
rule["status"] = "pending"
|
| 328 |
+
current_emails = sample_emails
|
| 329 |
+
|
| 330 |
+
new_folder_choices = get_folder_dropdown_choices(current_emails)
|
| 331 |
+
new_folder_value = new_folder_choices[0] if new_folder_choices else "Inbox (0)"
|
| 332 |
+
filtered_emails = filter_emails(new_folder_value, search_query, sort_option, current_emails)
|
| 333 |
+
new_email_display = create_email_html(filtered_emails, new_folder_value)
|
| 334 |
+
|
| 335 |
+
return (gr.update(choices=new_folder_choices, value=new_folder_value),
|
| 336 |
+
new_email_display,
|
| 337 |
+
create_interactive_rule_cards(pending_rules),
|
| 338 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 339 |
+
"🔄 Exited preview mode",
|
| 340 |
+
pending_rules, current_emails)
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def get_preview_rules_count(pending_rules):
|
| 344 |
+
"""Get the number of rules currently in preview status"""
|
| 345 |
+
return len([rule for rule in pending_rules if rule["status"] == "preview"])
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def create_app(modal_url: str) -> gr.Blocks:
|
| 349 |
+
"""
|
| 350 |
+
Create the main Gradio app
|
| 351 |
+
|
| 352 |
+
Args:
|
| 353 |
+
modal_url: URL of the Modal backend server
|
| 354 |
+
|
| 355 |
+
Returns:
|
| 356 |
+
Gradio Blocks app
|
| 357 |
+
"""
|
| 358 |
+
|
| 359 |
+
# Create the Gradio interface
|
| 360 |
+
with gr.Blocks(title="Email Rule Agent", theme=gr.themes.Soft()) as demo:
|
| 361 |
+
# Initialize state variables
|
| 362 |
+
session_id = gr.State(value=str(uuid.uuid4()))
|
| 363 |
+
mcp_client_state = gr.State()
|
| 364 |
+
|
| 365 |
+
# Email state
|
| 366 |
+
sample_emails_state = gr.State([])
|
| 367 |
+
preview_emails_state = gr.State([])
|
| 368 |
+
current_emails_state = gr.State([])
|
| 369 |
+
|
| 370 |
+
# Rule state
|
| 371 |
+
pending_rules_state = gr.State([])
|
| 372 |
+
applied_rules_state = gr.State([])
|
| 373 |
+
rule_counter_state = gr.State(0)
|
| 374 |
+
|
| 375 |
+
gr.Markdown("# 📧 Email Rule Agent")
|
| 376 |
+
gr.Markdown("*Intelligent email management powered by AI*")
|
| 377 |
+
|
| 378 |
+
# Add login choice
|
| 379 |
+
with gr.Row(visible=True) as login_row:
|
| 380 |
+
with gr.Column():
|
| 381 |
+
gr.Markdown("## Welcome! Choose how to get started:")
|
| 382 |
+
with gr.Row():
|
| 383 |
+
demo_btn = gr.Button("🎮 Try with Demo Data", variant="primary", scale=1)
|
| 384 |
+
gmail_btn = gr.Button("📧 Login with Gmail", variant="secondary", scale=1)
|
| 385 |
+
|
| 386 |
+
# Main interface (initially hidden)
|
| 387 |
+
with gr.Row(visible=False) as main_interface:
|
| 388 |
+
# Left side - Chat Interface
|
| 389 |
+
with gr.Column(scale=4):
|
| 390 |
+
with gr.Tabs():
|
| 391 |
+
with gr.Tab("💬 Chat"):
|
| 392 |
+
chatbot = gr.Chatbot(
|
| 393 |
+
value=[],
|
| 394 |
+
type="messages",
|
| 395 |
+
placeholder="Welcome! I can help you organize your emails. Try:\n• 'Analyze my inbox'\n• 'Create a rule for newsletters'\n• 'Move work emails to a folder'",
|
| 396 |
+
render_markdown=True,
|
| 397 |
+
sanitize_html=False # Allow our custom HTML for tool messages
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
with gr.Row():
|
| 401 |
+
msg_input = gr.Textbox(
|
| 402 |
+
placeholder="Describe how you want to organize your emails...",
|
| 403 |
+
container=False,
|
| 404 |
+
scale=4
|
| 405 |
+
)
|
| 406 |
+
send_btn = gr.Button("Send", scale=1, variant="primary")
|
| 407 |
+
|
| 408 |
+
# Quick action buttons
|
| 409 |
+
with gr.Row():
|
| 410 |
+
analyze_btn = gr.Button("🔍 Analyze Inbox", scale=1, size="sm")
|
| 411 |
+
suggest_btn = gr.Button("💡 Suggest Rules", scale=1, size="sm")
|
| 412 |
+
|
| 413 |
+
with gr.Tab("📋 Rules"):
|
| 414 |
+
gr.Markdown("### 📋 Email Rules")
|
| 415 |
+
|
| 416 |
+
# Hidden components for JavaScript interaction
|
| 417 |
+
with gr.Row(visible=False):
|
| 418 |
+
preview_btn = gr.Button("Preview", elem_id="hidden_preview_btn")
|
| 419 |
+
accept_btn = gr.Button("Accept", elem_id="hidden_accept_btn")
|
| 420 |
+
reject_btn = gr.Button("Reject", elem_id="hidden_reject_btn")
|
| 421 |
+
exit_preview_btn = gr.Button("Exit Preview", elem_id="hidden_exit_preview_btn")
|
| 422 |
+
|
| 423 |
+
current_rule_id = gr.Textbox(visible=False, elem_id="current_rule_id")
|
| 424 |
+
status_msg = gr.Textbox(
|
| 425 |
+
label="Status",
|
| 426 |
+
placeholder="Ready...",
|
| 427 |
+
interactive=False,
|
| 428 |
+
visible=True
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
# Rule cards display
|
| 432 |
+
rule_cards = gr.HTML(create_interactive_rule_cards([]))
|
| 433 |
+
|
| 434 |
+
# Right side - Email Display
|
| 435 |
+
with gr.Column(scale=6):
|
| 436 |
+
# Preview banner
|
| 437 |
+
preview_banner = gr.HTML(create_preview_banner(0))
|
| 438 |
+
|
| 439 |
+
# Email controls
|
| 440 |
+
with gr.Row():
|
| 441 |
+
folder_dropdown = gr.Dropdown(
|
| 442 |
+
choices=["Inbox (0)"],
|
| 443 |
+
value="Inbox (0)",
|
| 444 |
+
container=False,
|
| 445 |
+
scale=2
|
| 446 |
+
)
|
| 447 |
+
search_box = gr.Textbox(
|
| 448 |
+
placeholder="Search emails...",
|
| 449 |
+
container=False,
|
| 450 |
+
scale=3
|
| 451 |
+
)
|
| 452 |
+
sort_dropdown = gr.Dropdown(
|
| 453 |
+
choices=["Newest First", "Oldest First"],
|
| 454 |
+
value="Newest First",
|
| 455 |
+
container=False,
|
| 456 |
+
scale=1
|
| 457 |
+
)
|
| 458 |
+
|
| 459 |
+
# Email list
|
| 460 |
+
email_display = gr.HTML(
|
| 461 |
+
value=create_email_html([])
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
# Initialize data on load
|
| 465 |
+
def initialize_app(session_id):
|
| 466 |
+
"""Initialize app with data and MCP client"""
|
| 467 |
+
# Initialize MCP client
|
| 468 |
+
mcp_client = MCPClient(modal_url, session_token=session_id)
|
| 469 |
+
|
| 470 |
+
# Load initial data
|
| 471 |
+
sample_emails, _ = load_email_data(DATA_PATH)
|
| 472 |
+
preview_emails, _ = load_email_data(PREVIEW_DATA_PATH)
|
| 473 |
+
current_emails = sample_emails
|
| 474 |
+
loaded_rules, rule_counter = load_rules_from_json()
|
| 475 |
+
|
| 476 |
+
# Update UI components
|
| 477 |
+
folder_choices = get_folder_dropdown_choices(current_emails)
|
| 478 |
+
initial_folder = folder_choices[0] if folder_choices else "Inbox (0)"
|
| 479 |
+
initial_email_html = create_email_html(current_emails)
|
| 480 |
+
initial_rule_cards = create_interactive_rule_cards(loaded_rules)
|
| 481 |
+
initial_preview_banner = create_preview_banner(get_preview_rules_count(loaded_rules))
|
| 482 |
+
|
| 483 |
+
return (
|
| 484 |
+
mcp_client, # mcp_client_state
|
| 485 |
+
sample_emails, # sample_emails_state
|
| 486 |
+
preview_emails, # preview_emails_state
|
| 487 |
+
current_emails, # current_emails_state
|
| 488 |
+
loaded_rules, # pending_rules_state
|
| 489 |
+
[], # applied_rules_state
|
| 490 |
+
rule_counter, # rule_counter_state
|
| 491 |
+
gr.update(choices=folder_choices, value=initial_folder), # folder_dropdown
|
| 492 |
+
initial_email_html, # email_display
|
| 493 |
+
initial_rule_cards, # rule_cards
|
| 494 |
+
initial_preview_banner # preview_banner
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
# Event handlers
|
| 498 |
+
def start_demo_mode(session_id):
|
| 499 |
+
# Initialize and set session to demo mode
|
| 500 |
+
results = initialize_app(session_id)
|
| 501 |
+
mcp_client = results[0]
|
| 502 |
+
mcp_client.set_mode('mock')
|
| 503 |
+
return (
|
| 504 |
+
*results,
|
| 505 |
+
gr.update(visible=False), # login_row
|
| 506 |
+
gr.update(visible=True) # main_interface
|
| 507 |
+
)
|
| 508 |
+
|
| 509 |
+
def start_gmail_mode(session_id):
|
| 510 |
+
# Initialize and get Gmail OAuth URL
|
| 511 |
+
results = initialize_app(session_id)
|
| 512 |
+
mcp_client = results[0]
|
| 513 |
+
auth_url = mcp_client.get_gmail_auth_url()
|
| 514 |
+
if auth_url:
|
| 515 |
+
# In a real app, we'd redirect to this URL
|
| 516 |
+
print(f"Gmail auth URL: {auth_url}")
|
| 517 |
+
return (
|
| 518 |
+
*results,
|
| 519 |
+
gr.update(visible=False), # login_row
|
| 520 |
+
gr.update(visible=True) # main_interface
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
demo_btn.click(
|
| 524 |
+
start_demo_mode,
|
| 525 |
+
inputs=[session_id],
|
| 526 |
+
outputs=[
|
| 527 |
+
mcp_client_state, sample_emails_state, preview_emails_state, current_emails_state,
|
| 528 |
+
pending_rules_state, applied_rules_state, rule_counter_state,
|
| 529 |
+
folder_dropdown, email_display, rule_cards, preview_banner,
|
| 530 |
+
login_row, main_interface
|
| 531 |
+
]
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
gmail_btn.click(
|
| 535 |
+
start_gmail_mode,
|
| 536 |
+
inputs=[session_id],
|
| 537 |
+
outputs=[
|
| 538 |
+
mcp_client_state, sample_emails_state, preview_emails_state, current_emails_state,
|
| 539 |
+
pending_rules_state, applied_rules_state, rule_counter_state,
|
| 540 |
+
folder_dropdown, email_display, rule_cards, preview_banner,
|
| 541 |
+
login_row, main_interface
|
| 542 |
+
]
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
# Use the streaming chat handler
|
| 546 |
+
|
| 547 |
+
send_btn.click(
|
| 548 |
+
process_chat_streaming,
|
| 549 |
+
inputs=[msg_input, chatbot, mcp_client_state, current_emails_state,
|
| 550 |
+
pending_rules_state, applied_rules_state, rule_counter_state],
|
| 551 |
+
outputs=[chatbot, msg_input, rule_cards, status_msg,
|
| 552 |
+
pending_rules_state, rule_counter_state],
|
| 553 |
+
show_progress="full" # Show progress for streaming
|
| 554 |
+
)
|
| 555 |
+
|
| 556 |
+
msg_input.submit(
|
| 557 |
+
process_chat_streaming,
|
| 558 |
+
inputs=[msg_input, chatbot, mcp_client_state, current_emails_state,
|
| 559 |
+
pending_rules_state, applied_rules_state, rule_counter_state],
|
| 560 |
+
outputs=[chatbot, msg_input, rule_cards, status_msg,
|
| 561 |
+
pending_rules_state, rule_counter_state],
|
| 562 |
+
show_progress="full"
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
+
# Quick action handlers with streaming
|
| 566 |
+
def analyze_inbox(hist, mcp, emails, pend, appl, cnt):
|
| 567 |
+
yield from process_chat_streaming(
|
| 568 |
+
"Analyze my inbox and suggest organization rules",
|
| 569 |
+
hist, mcp, emails, pend, appl, cnt
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
analyze_btn.click(
|
| 573 |
+
analyze_inbox,
|
| 574 |
+
inputs=[chatbot, mcp_client_state, current_emails_state,
|
| 575 |
+
pending_rules_state, applied_rules_state, rule_counter_state],
|
| 576 |
+
outputs=[chatbot, msg_input, rule_cards, status_msg,
|
| 577 |
+
pending_rules_state, rule_counter_state],
|
| 578 |
+
show_progress="full"
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
def suggest_rules(hist, mcp, emails, pend, appl, cnt):
|
| 582 |
+
yield from process_chat_streaming(
|
| 583 |
+
"Suggest some email organization rules based on common patterns",
|
| 584 |
+
hist, mcp, emails, pend, appl, cnt
|
| 585 |
+
)
|
| 586 |
+
|
| 587 |
+
suggest_btn.click(
|
| 588 |
+
suggest_rules,
|
| 589 |
+
inputs=[chatbot, mcp_client_state, current_emails_state,
|
| 590 |
+
pending_rules_state, applied_rules_state, rule_counter_state],
|
| 591 |
+
outputs=[chatbot, msg_input, rule_cards, status_msg,
|
| 592 |
+
pending_rules_state, rule_counter_state],
|
| 593 |
+
show_progress="full"
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
# Email display handlers
|
| 597 |
+
def update_emails(folder, sort_option, search_query, current_emails):
|
| 598 |
+
filtered_emails = filter_emails(folder, search_query, sort_option, current_emails)
|
| 599 |
+
return create_email_html(filtered_emails, folder)
|
| 600 |
+
|
| 601 |
+
folder_dropdown.change(
|
| 602 |
+
update_emails,
|
| 603 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, current_emails_state],
|
| 604 |
+
outputs=[email_display]
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
sort_dropdown.change(
|
| 608 |
+
update_emails,
|
| 609 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, current_emails_state],
|
| 610 |
+
outputs=[email_display]
|
| 611 |
+
)
|
| 612 |
+
|
| 613 |
+
search_box.change(
|
| 614 |
+
update_emails,
|
| 615 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, current_emails_state],
|
| 616 |
+
outputs=[email_display]
|
| 617 |
+
)
|
| 618 |
+
|
| 619 |
+
# Rule action handlers
|
| 620 |
+
preview_btn.click(
|
| 621 |
+
handle_preview_rule,
|
| 622 |
+
inputs=[current_rule_id, sort_dropdown, search_box, mcp_client_state,
|
| 623 |
+
pending_rules_state, current_emails_state, sample_emails_state, preview_emails_state],
|
| 624 |
+
outputs=[folder_dropdown, email_display, rule_cards, preview_banner, status_msg,
|
| 625 |
+
pending_rules_state, current_emails_state]
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
accept_btn.click(
|
| 629 |
+
lambda rule_id, folder, sort, search, mcp, pend, appl, curr, samp: accept_rule_with_mcp(
|
| 630 |
+
rule_id, mcp, folder, sort, search, pend, appl, curr, samp),
|
| 631 |
+
inputs=[current_rule_id, folder_dropdown, sort_dropdown, search_box, mcp_client_state,
|
| 632 |
+
pending_rules_state, applied_rules_state, current_emails_state, sample_emails_state],
|
| 633 |
+
outputs=[rule_cards, preview_banner, status_msg, email_display, folder_dropdown,
|
| 634 |
+
pending_rules_state, applied_rules_state, current_emails_state, sample_emails_state]
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
reject_btn.click(
|
| 638 |
+
reject_rule,
|
| 639 |
+
inputs=[current_rule_id, pending_rules_state],
|
| 640 |
+
outputs=[rule_cards, preview_banner, status_msg, pending_rules_state]
|
| 641 |
+
)
|
| 642 |
+
|
| 643 |
+
exit_preview_btn.click(
|
| 644 |
+
lambda sort, search, pend, curr, samp: exit_preview_mode(sort, search, pend, curr, samp),
|
| 645 |
+
inputs=[sort_dropdown, search_box, pending_rules_state, current_emails_state, sample_emails_state],
|
| 646 |
+
outputs=[folder_dropdown, email_display, rule_cards, preview_banner, status_msg,
|
| 647 |
+
pending_rules_state, current_emails_state]
|
| 648 |
+
)
|
| 649 |
+
|
| 650 |
+
# Load JavaScript handlers
|
| 651 |
+
demo.load(
|
| 652 |
+
None,
|
| 653 |
+
inputs=None,
|
| 654 |
+
outputs=None,
|
| 655 |
+
js=get_javascript_handlers()
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
return demo
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
def launch_app(demo: gr.Blocks):
|
| 662 |
+
"""Launch the Gradio app"""
|
| 663 |
+
demo.launch()
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
def get_javascript_handlers():
|
| 667 |
+
"""Get JavaScript code for handling UI interactions"""
|
| 668 |
+
return """
|
| 669 |
+
() => {
|
| 670 |
+
function setupButtonHandlers() {
|
| 671 |
+
// Remove existing listeners to prevent duplicates
|
| 672 |
+
document.removeEventListener('click', globalClickHandler);
|
| 673 |
+
|
| 674 |
+
// Add global click handler
|
| 675 |
+
document.addEventListener('click', globalClickHandler);
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
function globalClickHandler(e) {
|
| 679 |
+
if (e.target.classList.contains('preview-btn')) {
|
| 680 |
+
e.preventDefault();
|
| 681 |
+
handleRuleAction('preview', e.target.getAttribute('data-rule-id'));
|
| 682 |
+
} else if (e.target.classList.contains('accept-btn')) {
|
| 683 |
+
e.preventDefault();
|
| 684 |
+
handleRuleAction('accept', e.target.getAttribute('data-rule-id'));
|
| 685 |
+
} else if (e.target.classList.contains('reject-btn')) {
|
| 686 |
+
e.preventDefault();
|
| 687 |
+
handleRuleAction('reject', e.target.getAttribute('data-rule-id'));
|
| 688 |
+
} else if (e.target.id === 'exit-preview-btn') {
|
| 689 |
+
e.preventDefault();
|
| 690 |
+
handleExitPreview();
|
| 691 |
+
}
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
function handleExitPreview() {
|
| 695 |
+
console.log('Exit preview button clicked');
|
| 696 |
+
const exitButton = document.getElementById('hidden_exit_preview_btn');
|
| 697 |
+
if (exitButton) {
|
| 698 |
+
exitButton.click();
|
| 699 |
+
} else {
|
| 700 |
+
console.error('Hidden exit preview button not found');
|
| 701 |
+
}
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
function handleRuleAction(action, ruleId) {
|
| 705 |
+
console.log(`handleRuleAction called with action: ${action}, ruleId: ${ruleId}`);
|
| 706 |
+
|
| 707 |
+
if (!ruleId) {
|
| 708 |
+
console.error('No ruleId provided');
|
| 709 |
+
return;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
// Find the hidden textbox
|
| 713 |
+
const container = document.getElementById('current_rule_id');
|
| 714 |
+
let ruleIdInput = null;
|
| 715 |
+
|
| 716 |
+
if (container) {
|
| 717 |
+
ruleIdInput = container.querySelector('input, textarea');
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
if (ruleIdInput) {
|
| 721 |
+
console.log(`Setting rule ID input to: ${ruleId}`);
|
| 722 |
+
ruleIdInput.value = ruleId;
|
| 723 |
+
|
| 724 |
+
// Dispatch events
|
| 725 |
+
ruleIdInput.dispatchEvent(new Event('input', { bubbles: true }));
|
| 726 |
+
ruleIdInput.dispatchEvent(new Event('change', { bubbles: true }));
|
| 727 |
+
|
| 728 |
+
// Trigger the appropriate button after a delay
|
| 729 |
+
setTimeout(() => {
|
| 730 |
+
let targetButton;
|
| 731 |
+
if (action === 'preview') {
|
| 732 |
+
targetButton = document.getElementById('hidden_preview_btn');
|
| 733 |
+
} else if (action === 'accept') {
|
| 734 |
+
targetButton = document.getElementById('hidden_accept_btn');
|
| 735 |
+
} else if (action === 'reject') {
|
| 736 |
+
targetButton = document.getElementById('hidden_reject_btn');
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
if (targetButton) {
|
| 740 |
+
console.log(`Clicking ${action} button`);
|
| 741 |
+
targetButton.click();
|
| 742 |
+
} else {
|
| 743 |
+
console.error(`Target button not found for action: ${action}`);
|
| 744 |
+
}
|
| 745 |
+
}, 150);
|
| 746 |
+
} else {
|
| 747 |
+
console.error('Rule ID input field not found');
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
// Setup handlers immediately
|
| 752 |
+
setupButtonHandlers();
|
| 753 |
+
|
| 754 |
+
// Also setup handlers when the rule cards are updated
|
| 755 |
+
const observer = new MutationObserver(function(mutations) {
|
| 756 |
+
mutations.forEach(function(mutation) {
|
| 757 |
+
if (mutation.type === 'childList') {
|
| 758 |
+
setupButtonHandlers();
|
| 759 |
+
}
|
| 760 |
+
});
|
| 761 |
+
});
|
| 762 |
+
|
| 763 |
+
// Observe changes to the document body
|
| 764 |
+
observer.observe(document.body, {
|
| 765 |
+
childList: true,
|
| 766 |
+
subtree: true
|
| 767 |
+
});
|
| 768 |
+
}
|
| 769 |
+
"""
|
archive/ui_old/utils/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI utility functions
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from .display import (
|
| 6 |
+
create_email_html,
|
| 7 |
+
create_interactive_rule_cards,
|
| 8 |
+
create_preview_banner,
|
| 9 |
+
get_folder_counts,
|
| 10 |
+
filter_emails
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
from .helpers import (
|
| 14 |
+
get_preview_rules_count,
|
| 15 |
+
get_folder_dropdown_choices
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
__all__ = [
|
| 19 |
+
'create_email_html',
|
| 20 |
+
'create_interactive_rule_cards',
|
| 21 |
+
'create_preview_banner',
|
| 22 |
+
'get_folder_counts',
|
| 23 |
+
'filter_emails',
|
| 24 |
+
'get_preview_rules_count',
|
| 25 |
+
'get_folder_dropdown_choices'
|
| 26 |
+
]
|
archive/ui_old/utils/display.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI utility functions extracted from gradio/app.py
|
| 3 |
+
Making the UI component self-contained
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from typing import List, Dict, Any, Tuple
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_email_data(file_path: str) -> Tuple[List[Dict], str]:
|
| 14 |
+
"""Load email data from JSON file"""
|
| 15 |
+
try:
|
| 16 |
+
if os.path.exists(file_path):
|
| 17 |
+
with open(file_path, 'r', encoding='utf-8') as file:
|
| 18 |
+
mail_list = json.load(file)
|
| 19 |
+
return mail_list, f"Successfully loaded {len(mail_list)} emails"
|
| 20 |
+
else:
|
| 21 |
+
return [], f"File not found: {file_path}"
|
| 22 |
+
except json.JSONDecodeError as e:
|
| 23 |
+
return [], f"Error parsing JSON: {str(e)}"
|
| 24 |
+
except Exception as e:
|
| 25 |
+
return [], f"Error reading file: {str(e)}"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_folder_counts(emails: List[Dict]) -> List[Tuple[str, int]]:
|
| 29 |
+
"""Extract unique folders and their counts from email data"""
|
| 30 |
+
folder_counts = defaultdict(int)
|
| 31 |
+
|
| 32 |
+
for email in emails:
|
| 33 |
+
if 'folder' in email and email['folder']:
|
| 34 |
+
folder_counts[email['folder']] += 1
|
| 35 |
+
else:
|
| 36 |
+
folder_counts['Inbox'] += 1
|
| 37 |
+
|
| 38 |
+
return sorted(folder_counts.items())
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def get_folder_dropdown_choices(emails: List[Dict]) -> List[str]:
|
| 42 |
+
"""Get folder choices for dropdown in format 'Folder Name (Count)'"""
|
| 43 |
+
folder_counts = get_folder_counts(emails)
|
| 44 |
+
return [f"{folder_name} ({count})" for folder_name, count in folder_counts]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def extract_folder_name(folder_choice: str) -> str:
|
| 48 |
+
"""Extract folder name from dropdown choice format 'Folder Name (Count)'"""
|
| 49 |
+
if '(' in folder_choice:
|
| 50 |
+
return folder_choice.split(' (')[0]
|
| 51 |
+
return folder_choice
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def format_date(date_string: str) -> str:
|
| 55 |
+
"""Format ISO date string to readable format"""
|
| 56 |
+
try:
|
| 57 |
+
dt = datetime.fromisoformat(date_string.replace('Z', '+00:00'))
|
| 58 |
+
now = datetime.now()
|
| 59 |
+
|
| 60 |
+
if dt.date() == now.date():
|
| 61 |
+
return dt.strftime("%H:%M")
|
| 62 |
+
elif dt.year == now.year:
|
| 63 |
+
return dt.strftime("%b %d")
|
| 64 |
+
else:
|
| 65 |
+
return dt.strftime("%b %d, %Y")
|
| 66 |
+
except:
|
| 67 |
+
return date_string
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def sort_emails(emails: List[Dict], sort_option: str) -> List[Dict]:
|
| 71 |
+
"""Sort emails based on the selected sort option"""
|
| 72 |
+
if not emails:
|
| 73 |
+
return emails
|
| 74 |
+
|
| 75 |
+
def get_email_date(email):
|
| 76 |
+
try:
|
| 77 |
+
date_str = email.get('date', '')
|
| 78 |
+
if date_str:
|
| 79 |
+
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
| 80 |
+
else:
|
| 81 |
+
return datetime(1970, 1, 1)
|
| 82 |
+
except:
|
| 83 |
+
return datetime(1970, 1, 1)
|
| 84 |
+
|
| 85 |
+
if sort_option == "Newest First":
|
| 86 |
+
return sorted(emails, key=get_email_date, reverse=True)
|
| 87 |
+
elif sort_option == "Oldest First":
|
| 88 |
+
return sorted(emails, key=get_email_date, reverse=False)
|
| 89 |
+
else:
|
| 90 |
+
return emails
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def create_email_html(emails: List[Dict], selected_folder: str = "Inbox", expanded_email_id: str = None) -> str:
|
| 94 |
+
"""Create HTML for email list"""
|
| 95 |
+
folder_counts = dict(get_folder_counts(emails))
|
| 96 |
+
clean_folder_name = extract_folder_name(selected_folder)
|
| 97 |
+
|
| 98 |
+
html = """
|
| 99 |
+
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
| 100 |
+
<div style="border: 1px solid #e1e5e9; border-radius: 8px; overflow: hidden;">
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
for i, email in enumerate(emails):
|
| 104 |
+
tags_html = ""
|
| 105 |
+
folder = email.get("folder", "INBOX")
|
| 106 |
+
for label in email.get("labels", []):
|
| 107 |
+
if label.upper() == folder.upper():
|
| 108 |
+
result = "MATCH"
|
| 109 |
+
else:
|
| 110 |
+
result = "NO MATCH"
|
| 111 |
+
color = {"MATCH": "#007bff"}.get(result, "#6c757d")
|
| 112 |
+
|
| 113 |
+
tags_html += f'<span style="background: {color}; color: white; padding: 2px 6px; border-radius: 10px; font-size: 10px; margin-right: 5px;">{label}</span>'
|
| 114 |
+
|
| 115 |
+
border_bottom = "border-bottom: 1px solid #e1e5e9;" if i < len(emails) - 1 else ""
|
| 116 |
+
is_expanded = expanded_email_id == email.get('id')
|
| 117 |
+
|
| 118 |
+
sender_name = email.get('from_name', email.get('from_email', 'Unknown'))
|
| 119 |
+
subject = email.get('subject', 'No Subject')
|
| 120 |
+
snippet = email.get('snippet', '')
|
| 121 |
+
formatted_date = format_date(email.get('date', ''))
|
| 122 |
+
|
| 123 |
+
html += f"""
|
| 124 |
+
<div style="padding: 12px 15px; {border_bottom} hover:background-color: #f8f9fa; cursor: pointer;" onclick="toggleEmailExpansion('{email.get('id', f'email_{i}')}')">
|
| 125 |
+
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
|
| 126 |
+
<div style="flex: 1;">
|
| 127 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
| 128 |
+
<span style="font-weight: 600; color: #212529;">{sender_name}</span>
|
| 129 |
+
<div style="display: flex; align-items: center; gap: 10px;">
|
| 130 |
+
{tags_html}
|
| 131 |
+
<span style="color: #6c757d; font-size: 12px;">{formatted_date}</span>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
<div style="font-weight: 500; color: #495057; margin-bottom: 2px;">{subject}</div>
|
| 135 |
+
<div style="color: #6c757d; font-size: 13px;">{snippet if not is_expanded else ''}</div>
|
| 136 |
+
"""
|
| 137 |
+
|
| 138 |
+
if is_expanded and 'body' in email:
|
| 139 |
+
html += f"""
|
| 140 |
+
<div style="margin-top: 15px; padding: 15px; background: #f8f9fa; border-radius: 6px; border-left: 3px solid #007bff;">
|
| 141 |
+
<div style="color: #495057; line-height: 1.6; white-space: pre-line;">{email['body']}</div>
|
| 142 |
+
</div>
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
html += """
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
</div>
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
html += """
|
| 152 |
+
</div>
|
| 153 |
+
<script>
|
| 154 |
+
function toggleEmailExpansion(emailId) {
|
| 155 |
+
console.log('Toggle email:', emailId);
|
| 156 |
+
}
|
| 157 |
+
</script>
|
| 158 |
+
</div>"""
|
| 159 |
+
return html
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def filter_emails(folder: str, search_query: str = "", sort_option: str = "Newest First", emails_source: List[Dict] = None) -> List[Dict]:
|
| 163 |
+
"""Filter and sort emails based on folder, search query, and sort option"""
|
| 164 |
+
if emails_source is None:
|
| 165 |
+
return []
|
| 166 |
+
|
| 167 |
+
filtered = emails_source
|
| 168 |
+
clean_folder_name = extract_folder_name(folder)
|
| 169 |
+
|
| 170 |
+
# Filter by folder
|
| 171 |
+
folder_filtered = []
|
| 172 |
+
for email in filtered:
|
| 173 |
+
if 'folder' in email and email['folder'] == clean_folder_name:
|
| 174 |
+
folder_filtered.append(email)
|
| 175 |
+
elif not 'folder' in email and 'INBOX' == clean_folder_name:
|
| 176 |
+
folder_filtered.append(email)
|
| 177 |
+
|
| 178 |
+
filtered = folder_filtered
|
| 179 |
+
|
| 180 |
+
# Filter by search query
|
| 181 |
+
if search_query:
|
| 182 |
+
filtered = [email for email in filtered if
|
| 183 |
+
search_query.lower() in email.get('from_name', '').lower() or
|
| 184 |
+
search_query.lower() in email.get('from_email', '').lower() or
|
| 185 |
+
search_query.lower() in email.get('subject', '').lower() or
|
| 186 |
+
search_query.lower() in email.get('snippet', '').lower()]
|
| 187 |
+
|
| 188 |
+
# Sort emails
|
| 189 |
+
filtered = sort_emails(filtered, sort_option)
|
| 190 |
+
|
| 191 |
+
return filtered
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def create_preview_banner(preview_rules_count: int = 0) -> str:
|
| 195 |
+
"""Create the preview mode banner HTML"""
|
| 196 |
+
if preview_rules_count == 0:
|
| 197 |
+
return ""
|
| 198 |
+
|
| 199 |
+
return f"""
|
| 200 |
+
<div id="preview-banner" style="background: #fd7e14; color: white; padding: 12px 20px; margin-bottom: 15px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; box-shadow: 0 2px 8px rgba(253, 126, 20, 0.3);">
|
| 201 |
+
<div style="display: flex; align-items: center; gap: 10px;">
|
| 202 |
+
<span style="font-size: 18px;">👁️</span>
|
| 203 |
+
<span style="font-weight: 600; font-size: 14px;">
|
| 204 |
+
Preview Mode: emails would be organized by {preview_rules_count} rule{'s' if preview_rules_count != 1 else ''}
|
| 205 |
+
</span>
|
| 206 |
+
</div>
|
| 207 |
+
<button id="exit-preview-btn" style="background: rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.4); color: white; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.2s;">
|
| 208 |
+
Exit Preview
|
| 209 |
+
</button>
|
| 210 |
+
</div>
|
| 211 |
+
"""
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def create_interactive_rule_cards(pending_rules: List[Dict]) -> str:
|
| 215 |
+
"""Create HTML for interactive rule cards display"""
|
| 216 |
+
if not pending_rules:
|
| 217 |
+
return """
|
| 218 |
+
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; text-align: center; color: #6c757d; background: #f8f9fa; border-radius: 10px; margin: 10px;">
|
| 219 |
+
<div style="font-size: 48px; margin-bottom: 10px;">📋</div>
|
| 220 |
+
<h3 style="margin: 10px 0; color: #495057;">No Pending Rules</h3>
|
| 221 |
+
<p style="margin: 0; font-size: 14px;">Ask me to analyze your inbox to get started!</p>
|
| 222 |
+
</div>
|
| 223 |
+
"""
|
| 224 |
+
|
| 225 |
+
html = f"""
|
| 226 |
+
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-height: 400px; overflow-y: auto; padding: 10px;">
|
| 227 |
+
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding: 0 10px;">
|
| 228 |
+
<span style="font-size: 24px;">📋</span>
|
| 229 |
+
<h3 style="color: #495057; margin: 0; font-size: 18px; font-weight: 600;">Email Rules</h3>
|
| 230 |
+
<span style="background: #e9ecef; color: #495057; padding: 4px 8px; border-radius: 12px; font-size: 12px; font-weight: 500;">
|
| 231 |
+
{len(pending_rules)} loaded
|
| 232 |
+
</span>
|
| 233 |
+
</div>
|
| 234 |
+
"""
|
| 235 |
+
|
| 236 |
+
for rule in pending_rules:
|
| 237 |
+
status_color = {
|
| 238 |
+
"pending": "#ffc107",
|
| 239 |
+
"accepted": "#28a745",
|
| 240 |
+
"rejected": "#dc3545",
|
| 241 |
+
"preview": "#fd7e14"
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
status_text = {
|
| 245 |
+
"pending": "⏳ Pending Review",
|
| 246 |
+
"accepted": "✅ Accepted",
|
| 247 |
+
"rejected": "❌ Rejected",
|
| 248 |
+
"preview": "👁️ Preview"
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
conditions_html = "<br>".join([f"• {cond}" for cond in rule.get('conditions', [])])
|
| 252 |
+
actions_html = "<br>".join([f"• {action}" for action in rule.get('actions', [])])
|
| 253 |
+
|
| 254 |
+
card_bg = "white"
|
| 255 |
+
card_opacity = "1"
|
| 256 |
+
border_color = "#e1e5e9"
|
| 257 |
+
|
| 258 |
+
if rule['status'] == "accepted":
|
| 259 |
+
card_bg = "#d4edda"
|
| 260 |
+
elif rule['status'] == "rejected":
|
| 261 |
+
card_bg = "#f8d7da"
|
| 262 |
+
card_opacity = "0.6"
|
| 263 |
+
elif rule['status'] == "preview":
|
| 264 |
+
card_bg = "#fff3cd"
|
| 265 |
+
border_color = "#fd7e14"
|
| 266 |
+
|
| 267 |
+
buttons_html = ""
|
| 268 |
+
if rule['status'] == "pending":
|
| 269 |
+
buttons_html = f"""
|
| 270 |
+
<div style="display: flex; gap: 8px; margin-top: 15px; flex-wrap: wrap;">
|
| 271 |
+
<button class="preview-btn" data-rule-id="{rule['id']}"
|
| 272 |
+
style="padding: 8px 16px; border: 1px solid #007bff; background: white; color: #007bff; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;">
|
| 273 |
+
👁️ Preview
|
| 274 |
+
</button>
|
| 275 |
+
<button class="accept-btn" data-rule-id="{rule['id']}"
|
| 276 |
+
style="padding: 8px 16px; border: none; background: #28a745; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;">
|
| 277 |
+
✅ Accept
|
| 278 |
+
</button>
|
| 279 |
+
<button class="reject-btn" data-rule-id="{rule['id']}"
|
| 280 |
+
style="padding: 8px 16px; border: none; background: #dc3545; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;">
|
| 281 |
+
❌ Reject
|
| 282 |
+
</button>
|
| 283 |
+
</div>
|
| 284 |
+
"""
|
| 285 |
+
elif rule['status'] == "preview":
|
| 286 |
+
buttons_html = f"""
|
| 287 |
+
<div style="display: flex; gap: 8px; margin-top: 15px; flex-wrap: wrap;">
|
| 288 |
+
<button class="accept-btn" data-rule-id="{rule['id']}"
|
| 289 |
+
style="padding: 8px 16px; border: none; background: #28a745; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;">
|
| 290 |
+
✅ Accept
|
| 291 |
+
</button>
|
| 292 |
+
<button class="reject-btn" data-rule-id="{rule['id']}"
|
| 293 |
+
style="padding: 8px 16px; border: none; background: #dc3545; color: white; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.2s;">
|
| 294 |
+
❌ Reject
|
| 295 |
+
</button>
|
| 296 |
+
</div>
|
| 297 |
+
"""
|
| 298 |
+
|
| 299 |
+
# Format conditions and actions properly
|
| 300 |
+
if isinstance(rule.get('conditions'), list) and len(rule['conditions']) > 0:
|
| 301 |
+
if isinstance(rule['conditions'][0], dict):
|
| 302 |
+
# New format from agent
|
| 303 |
+
conditions_html = "<br>".join([
|
| 304 |
+
f"• {cond.get('field', 'field')} {cond.get('operator', 'contains')} '{cond.get('value', '')}'"
|
| 305 |
+
for cond in rule['conditions']
|
| 306 |
+
])
|
| 307 |
+
else:
|
| 308 |
+
# Old format
|
| 309 |
+
conditions_html = "<br>".join([f"• {cond}" for cond in rule['conditions']])
|
| 310 |
+
|
| 311 |
+
if isinstance(rule.get('actions'), list) and len(rule['actions']) > 0:
|
| 312 |
+
if isinstance(rule['actions'][0], dict):
|
| 313 |
+
# New format from agent
|
| 314 |
+
actions_html = "<br>".join([
|
| 315 |
+
f"• {action.get('type', 'action')} to '{action.get('parameters', {}).get('folder', 'folder')}'"
|
| 316 |
+
if action.get('type') == 'move' else f"• {action.get('type', 'action')}"
|
| 317 |
+
for action in rule['actions']
|
| 318 |
+
])
|
| 319 |
+
else:
|
| 320 |
+
# Old format
|
| 321 |
+
actions_html = "<br>".join([f"• {action}" for action in rule['actions']])
|
| 322 |
+
|
| 323 |
+
html += f"""
|
| 324 |
+
<div id="rule-{rule['id']}" style="border: 2px solid {border_color}; border-radius: 12px; padding: 20px; margin: 10px 0; background: {card_bg}; opacity: {card_opacity}; box-shadow: 0 2px 8px rgba(0,0,0,0.08); transition: all 0.2s;">
|
| 325 |
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; flex-wrap: wrap; gap: 10px;">
|
| 326 |
+
<h4 style="margin: 0; color: #495057; font-size: 16px; font-weight: 600; flex: 1; min-width: 200px;">{rule.get('name', 'Unnamed Rule')}</h4>
|
| 327 |
+
<span style="background: {status_color[rule['status']]}; color: white; padding: 4px 10px; border-radius: 15px; font-size: 11px; font-weight: 600; text-transform: uppercase; white-space: nowrap;">
|
| 328 |
+
{status_text[rule['status']]}
|
| 329 |
+
</span>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<p style="color: #6c757d; margin: 10px 0; font-size: 14px; line-height: 1.5;">{rule.get('description', '')}</p>
|
| 333 |
+
|
| 334 |
+
<div style="margin: 15px 0;">
|
| 335 |
+
<strong style="color: #495057; font-size: 13px; display: block; margin-bottom: 6px;">🎯 Conditions:</strong>
|
| 336 |
+
<div style="margin-left: 10px; font-size: 12px; color: #6c757d; line-height: 1.6; background: #f8f9fa; padding: 8px; border-radius: 6px;">{conditions_html}</div>
|
| 337 |
+
</div>
|
| 338 |
+
|
| 339 |
+
<div style="margin: 15px 0;">
|
| 340 |
+
<strong style="color: #495057; font-size: 13px; display: block; margin-bottom: 6px;">⚡ Actions:</strong>
|
| 341 |
+
<div style="margin-left: 10px; font-size: 12px; color: #6c757d; line-height: 1.6; background: #f8f9fa; padding: 8px; border-radius: 6px;">{actions_html}</div>
|
| 342 |
+
</div>
|
| 343 |
+
|
| 344 |
+
{buttons_html}
|
| 345 |
+
</div>
|
| 346 |
+
"""
|
| 347 |
+
|
| 348 |
+
html += """
|
| 349 |
+
</div>
|
| 350 |
+
"""
|
| 351 |
+
return html
|
archive/ui_old/utils/helpers.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Helper functions extracted from display utilities
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List, Dict, Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def get_preview_rules_count(pending_rules: List[Dict[str, Any]]) -> int:
|
| 9 |
+
"""Get the number of rules currently in preview status"""
|
| 10 |
+
return len([rule for rule in pending_rules if rule.get("status") == "preview"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_folder_dropdown_choices(emails: List[Dict[str, Any]]) -> List[str]:
|
| 14 |
+
"""
|
| 15 |
+
Get folder choices for dropdown in format 'Folder Name (Count)'
|
| 16 |
+
Moved here to avoid circular imports
|
| 17 |
+
"""
|
| 18 |
+
from .display import get_folder_counts
|
| 19 |
+
|
| 20 |
+
folder_counts = get_folder_counts(emails)
|
| 21 |
+
return [f"{folder_name} ({count})" for folder_name, count in folder_counts]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def filter_emails(folder: str, search_query: str, sort_option: str, emails: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 25 |
+
"""
|
| 26 |
+
Delegate to display module's filter_emails
|
| 27 |
+
"""
|
| 28 |
+
from .display import filter_emails as display_filter_emails
|
| 29 |
+
return display_filter_emails(folder, search_query, sort_option, emails)
|
components/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
components/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Components package for Email Rule Agent
|
components/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (167 Bytes). View file
|
|
|
components/__pycache__/agent.cpython-313.pyc
ADDED
|
Binary file (16.6 kB). View file
|
|
|
components/__pycache__/agent_streaming.cpython-313.pyc
ADDED
|
Binary file (5.25 kB). View file
|
|
|
components/__pycache__/mcp_client.cpython-313.pyc
ADDED
|
Binary file (22 kB). View file
|
|
|
components/__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (2.89 kB). View file
|
|
|
components/__pycache__/session_manager.cpython-313.pyc
ADDED
|
Binary file (6.46 kB). View file
|
|
|
components/__pycache__/simple_agent.cpython-313.pyc
ADDED
|
Binary file (23.3 kB). View file
|
|
|
components/__pycache__/ui.cpython-313.pyc
ADDED
|
Binary file (75.9 kB). View file
|
|
|
components/__pycache__/ui_chat.cpython-313.pyc
ADDED
|
Binary file (7.97 kB). View file
|
|
|
components/__pycache__/ui_streaming.cpython-313.pyc
ADDED
|
Binary file (6.08 kB). View file
|
|
|
components/__pycache__/ui_streaming_fixed.cpython-313.pyc
ADDED
|
Binary file (7.53 kB). View file
|
|
|
components/__pycache__/ui_tools.cpython-313.pyc
ADDED
|
Binary file (8.16 kB). View file
|
|
|
components/__pycache__/ui_utils.cpython-313.pyc
ADDED
|
Binary file (32.1 kB). View file
|
|
|
components/agent_streaming.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Streaming version of the agent that yields tokens as they come
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
from typing import Generator, Dict, Any, List
|
| 9 |
+
from langchain_openai import ChatOpenAI
|
| 10 |
+
from langchain.schema import HumanMessage, SystemMessage
|
| 11 |
+
from langchain.callbacks.base import BaseCallbackHandler
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class StreamingCallbackHandler(BaseCallbackHandler):
|
| 15 |
+
"""Callback handler for streaming LLM responses"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.tokens = []
|
| 19 |
+
self.current_token = ""
|
| 20 |
+
|
| 21 |
+
def on_llm_new_token(self, token: str, **kwargs) -> None:
|
| 22 |
+
"""Called when LLM generates a new token"""
|
| 23 |
+
self.current_token = token
|
| 24 |
+
self.tokens.append(token)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def analyze_emails_streaming(
|
| 28 |
+
emails: List[Dict[str, Any]],
|
| 29 |
+
focus_area: str = "all types"
|
| 30 |
+
) -> Generator[Dict[str, Any], None, None]:
|
| 31 |
+
"""Analyze emails and stream the response"""
|
| 32 |
+
|
| 33 |
+
if not emails:
|
| 34 |
+
yield {
|
| 35 |
+
'type': 'error',
|
| 36 |
+
'content': "No emails available to analyze."
|
| 37 |
+
}
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
# Prepare email summaries
|
| 41 |
+
email_summaries = []
|
| 42 |
+
for e in emails[:30]:
|
| 43 |
+
email_summaries.append({
|
| 44 |
+
'from': e.get('from_name', ''),
|
| 45 |
+
'subject': e.get('subject', ''),
|
| 46 |
+
'preview': (e.get('body', '')[:150] + '...') if len(e.get('body', '')) > 150 else e.get('body', '')
|
| 47 |
+
})
|
| 48 |
+
|
| 49 |
+
prompt = f"""Analyze these {len(email_summaries)} email samples and propose 3-5 organization rules.
|
| 50 |
+
|
| 51 |
+
Focus area: {focus_area}
|
| 52 |
+
|
| 53 |
+
Email samples:
|
| 54 |
+
{json.dumps(email_summaries, indent=2)}
|
| 55 |
+
|
| 56 |
+
Create practical rules and explain your reasoning. Then provide a JSON block with this structure:
|
| 57 |
+
{{
|
| 58 |
+
"rules": [
|
| 59 |
+
{{
|
| 60 |
+
"name": "Rule name",
|
| 61 |
+
"description": "What this rule does",
|
| 62 |
+
"confidence": 0.8,
|
| 63 |
+
"conditions": [{{"field": "from", "operator": "contains", "value": "example.com"}}],
|
| 64 |
+
"actions": [{{"type": "move", "folder": "work"}}]
|
| 65 |
+
}}
|
| 66 |
+
]
|
| 67 |
+
}}"""
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
api_key = os.getenv('OPENROUTER_API_KEY')
|
| 71 |
+
if not api_key:
|
| 72 |
+
yield {'type': 'error', 'content': 'API key not configured'}
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
# Create streaming LLM
|
| 76 |
+
llm = ChatOpenAI(
|
| 77 |
+
openai_api_key=api_key,
|
| 78 |
+
openai_api_base="https://openrouter.ai/api/v1",
|
| 79 |
+
model_name='google/gemini-2.5-flash-preview-05-20',
|
| 80 |
+
temperature=0.7,
|
| 81 |
+
streaming=True
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Create callback handler
|
| 85 |
+
callback = StreamingCallbackHandler()
|
| 86 |
+
|
| 87 |
+
# Start streaming
|
| 88 |
+
messages = [
|
| 89 |
+
SystemMessage(content="You are an email pattern analyzer. Explain your analysis, then provide JSON."),
|
| 90 |
+
HumanMessage(content=prompt)
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
# Stream tokens
|
| 94 |
+
full_response = ""
|
| 95 |
+
for chunk in llm.stream(messages, config={"callbacks": [callback]}):
|
| 96 |
+
token = chunk.content
|
| 97 |
+
full_response += token
|
| 98 |
+
|
| 99 |
+
yield {
|
| 100 |
+
'type': 'token',
|
| 101 |
+
'content': token,
|
| 102 |
+
'full_response': full_response
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
# Extract rules from response
|
| 106 |
+
json_match = re.search(r'\{[\s\S]*\}', full_response)
|
| 107 |
+
if json_match:
|
| 108 |
+
try:
|
| 109 |
+
parsed = json.loads(json_match.group())
|
| 110 |
+
rules = parsed.get('rules', [])
|
| 111 |
+
|
| 112 |
+
# Format rules
|
| 113 |
+
formatted_rules = []
|
| 114 |
+
for rule in rules:
|
| 115 |
+
formatted_rule = {
|
| 116 |
+
"name": rule['name'],
|
| 117 |
+
"description": rule['description'],
|
| 118 |
+
"conditions": rule.get('conditions', []),
|
| 119 |
+
"actions": rule.get('actions', []),
|
| 120 |
+
"confidence": rule.get('confidence', 0.8),
|
| 121 |
+
"rule_id": f"rule_{hash(rule['name'])}_{len(formatted_rules)}"
|
| 122 |
+
}
|
| 123 |
+
formatted_rules.append(formatted_rule)
|
| 124 |
+
|
| 125 |
+
yield {
|
| 126 |
+
'type': 'rules',
|
| 127 |
+
'rules': formatted_rules,
|
| 128 |
+
'full_response': full_response
|
| 129 |
+
}
|
| 130 |
+
except:
|
| 131 |
+
yield {
|
| 132 |
+
'type': 'parse_error',
|
| 133 |
+
'full_response': full_response
|
| 134 |
+
}
|
| 135 |
+
else:
|
| 136 |
+
yield {
|
| 137 |
+
'type': 'no_rules',
|
| 138 |
+
'full_response': full_response
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
except Exception as e:
|
| 142 |
+
yield {
|
| 143 |
+
'type': 'error',
|
| 144 |
+
'content': str(e)
|
| 145 |
+
}
|
components/mcp_client.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
MCP Client for connecting to Modal backend
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
import requests
|
| 8 |
+
from typing import List, Dict, Any, Optional
|
| 9 |
+
from urllib.parse import urljoin
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MCPClient:
|
| 13 |
+
"""Client for communicating with Modal MCP backend"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, modal_url: str, session_token: Optional[str] = None):
|
| 16 |
+
"""
|
| 17 |
+
Initialize MCP client
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
modal_url: Base URL of the Modal backend or 'local://mock' for local mode
|
| 21 |
+
session_token: Optional session token for authenticated requests
|
| 22 |
+
"""
|
| 23 |
+
self.local_mode = modal_url == 'local://mock'
|
| 24 |
+
self.base_url = modal_url.rstrip('/') if not self.local_mode else 'local://mock'
|
| 25 |
+
self.session_token = session_token or self._generate_session_token()
|
| 26 |
+
self.headers = {
|
| 27 |
+
'Content-Type': 'application/json',
|
| 28 |
+
'X-Session-Token': self.session_token
|
| 29 |
+
}
|
| 30 |
+
self.mode = 'mock' # Default to mock mode
|
| 31 |
+
self._mock_sessions = {} # Store session data in memory
|
| 32 |
+
|
| 33 |
+
# Parse Modal URL components if it's a Modal URL
|
| 34 |
+
self.is_modal_url = not self.local_mode and "--" in modal_url and "modal.run" in modal_url
|
| 35 |
+
if self.is_modal_url:
|
| 36 |
+
# Extract from pattern: https://hfmcp--email-rule-agent-backend-dev.modal.run
|
| 37 |
+
# or https://hfmcp--email-rule-agent-backend-health-dev.modal.run
|
| 38 |
+
url_without_protocol = modal_url.replace("https://", "").replace("http://", "")
|
| 39 |
+
parts = url_without_protocol.split("--")
|
| 40 |
+
|
| 41 |
+
if len(parts) >= 2:
|
| 42 |
+
self.workspace = parts[0]
|
| 43 |
+
|
| 44 |
+
# The second part contains app-name.modal.run
|
| 45 |
+
# We need to extract just the app name
|
| 46 |
+
remaining = parts[1].replace(".modal.run", "")
|
| 47 |
+
|
| 48 |
+
# For URL like hfmcp--email-rule-agent-backend.modal.run
|
| 49 |
+
# We want: app_name = "email-rule-agent-backend"
|
| 50 |
+
self.app_name = remaining
|
| 51 |
+
|
| 52 |
+
# No environment suffix in deployed Modal URLs
|
| 53 |
+
self.env = ""
|
| 54 |
+
else:
|
| 55 |
+
# Fallback if URL doesn't match expected pattern
|
| 56 |
+
self.is_modal_url = False
|
| 57 |
+
|
| 58 |
+
def _generate_session_token(self) -> str:
|
| 59 |
+
"""Generate a random session token for demo users"""
|
| 60 |
+
import uuid
|
| 61 |
+
return str(uuid.uuid4())
|
| 62 |
+
|
| 63 |
+
def set_mode(self, mode: str):
|
| 64 |
+
"""Set the mode (mock or gmail)"""
|
| 65 |
+
if mode not in ['mock', 'gmail']:
|
| 66 |
+
raise ValueError("Mode must be 'mock' or 'gmail'")
|
| 67 |
+
self.mode = mode
|
| 68 |
+
self.headers['X-Mode'] = mode
|
| 69 |
+
|
| 70 |
+
def set_gmail_token(self, oauth_token: str):
|
| 71 |
+
"""Set Gmail OAuth token for authenticated requests"""
|
| 72 |
+
self.headers['Authorization'] = f'Bearer {oauth_token}'
|
| 73 |
+
self.mode = 'gmail'
|
| 74 |
+
|
| 75 |
+
def _construct_modal_endpoint_url(self, endpoint: str) -> str:
|
| 76 |
+
"""Construct the Modal-specific URL for an endpoint"""
|
| 77 |
+
if not self.is_modal_url:
|
| 78 |
+
# Fallback to regular path-based URL
|
| 79 |
+
return urljoin(self.base_url, endpoint)
|
| 80 |
+
|
| 81 |
+
# Map endpoints to Modal function names - now consolidated
|
| 82 |
+
endpoint_to_function = {
|
| 83 |
+
'/health': 'health',
|
| 84 |
+
'/emails': 'emails',
|
| 85 |
+
'/labels': 'labels',
|
| 86 |
+
'/rules/preview': 'rules_preview',
|
| 87 |
+
'/rules/apply': 'rules_apply',
|
| 88 |
+
'/rules': 'rules',
|
| 89 |
+
'/rules/save': 'rules',
|
| 90 |
+
'/sessions': 'sessions',
|
| 91 |
+
'/auth/gmail/url': 'auth',
|
| 92 |
+
'/auth/gmail/callback': 'auth',
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
# First check the direct mapping
|
| 96 |
+
function_name = endpoint_to_function.get(endpoint)
|
| 97 |
+
path_suffix = ""
|
| 98 |
+
|
| 99 |
+
if not function_name:
|
| 100 |
+
# Handle special cases
|
| 101 |
+
if endpoint.startswith('/rules/'):
|
| 102 |
+
# All rules operations go to 'rules'
|
| 103 |
+
function_name = 'rules'
|
| 104 |
+
# Preserve the path after /rules for PATCH and DELETE
|
| 105 |
+
if hasattr(self, '_current_method') and self._current_method in ['PATCH', 'DELETE']:
|
| 106 |
+
path_suffix = endpoint[6:] # Everything after '/rules'
|
| 107 |
+
elif endpoint.startswith('/sessions/'):
|
| 108 |
+
# All sessions operations go to 'sessions'
|
| 109 |
+
function_name = 'sessions'
|
| 110 |
+
# Preserve the path after /sessions
|
| 111 |
+
if hasattr(self, '_current_method') and self._current_method in ['GET', 'DELETE']:
|
| 112 |
+
path_suffix = endpoint[9:] # Everything after '/sessions'
|
| 113 |
+
elif endpoint.startswith('/auth/'):
|
| 114 |
+
# All auth operations go to 'auth'
|
| 115 |
+
function_name = 'auth'
|
| 116 |
+
else:
|
| 117 |
+
# Fallback: convert path to function name
|
| 118 |
+
function_name = endpoint.strip("/").replace("/", "_")
|
| 119 |
+
|
| 120 |
+
# Convert underscores to hyphens for Modal URLs
|
| 121 |
+
function_name_with_hyphens = function_name.replace('_', '-')
|
| 122 |
+
|
| 123 |
+
# Construct URL without environment suffix
|
| 124 |
+
base_url = f"https://{self.workspace}--{self.app_name}-{function_name_with_hyphens}.modal.run"
|
| 125 |
+
return base_url + path_suffix
|
| 126 |
+
|
| 127 |
+
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
| 128 |
+
"""Make a request to the Modal backend with unified error handling"""
|
| 129 |
+
# If in local mode, always use local responses
|
| 130 |
+
if self.local_mode:
|
| 131 |
+
return self._get_local_response(endpoint, method, kwargs)
|
| 132 |
+
|
| 133 |
+
# Store current method for URL construction
|
| 134 |
+
self._current_method = method
|
| 135 |
+
|
| 136 |
+
# Construct the appropriate URL
|
| 137 |
+
url = self._construct_modal_endpoint_url(endpoint)
|
| 138 |
+
|
| 139 |
+
# Handle special cases for Modal endpoints
|
| 140 |
+
if self.is_modal_url:
|
| 141 |
+
# For GET /sessions/{session_id}, Modal expects session_id as query param
|
| 142 |
+
if endpoint.startswith('/sessions/') and method == 'GET' and endpoint != '/sessions':
|
| 143 |
+
session_id = endpoint.split('/')[-1]
|
| 144 |
+
kwargs.setdefault('params', {})['session_id'] = session_id
|
| 145 |
+
|
| 146 |
+
# Note: PATCH and DELETE for /rules/{rule_id} now preserve the path structure
|
| 147 |
+
# so we don't need to move rule_id to query params
|
| 148 |
+
|
| 149 |
+
try:
|
| 150 |
+
response = requests.request(
|
| 151 |
+
method=method,
|
| 152 |
+
url=url,
|
| 153 |
+
headers=self.headers,
|
| 154 |
+
timeout=15, # Reduced timeout for better responsiveness
|
| 155 |
+
**kwargs
|
| 156 |
+
)
|
| 157 |
+
response.raise_for_status()
|
| 158 |
+
return response.json()
|
| 159 |
+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
|
| 160 |
+
# Backend is down or unreachable, use full local mock as fallback
|
| 161 |
+
print(f"MCPClient: Connection failed ('{e}'). Engaging fallback mode.")
|
| 162 |
+
return self._get_local_response(endpoint, method, kwargs)
|
| 163 |
+
except requests.exceptions.HTTPError as e:
|
| 164 |
+
# Backend is up but returned an error (4xx, 5xx)
|
| 165 |
+
print(f"MCPClient: HTTP Error: {e.response.status_code}")
|
| 166 |
+
try:
|
| 167 |
+
# Try to parse a JSON error response from the backend
|
| 168 |
+
error_details = e.response.json()
|
| 169 |
+
except ValueError:
|
| 170 |
+
error_details = {'error': e.response.text or 'An unknown HTTP error occurred.'}
|
| 171 |
+
|
| 172 |
+
return {
|
| 173 |
+
'success': False,
|
| 174 |
+
'status_code': e.response.status_code,
|
| 175 |
+
'error': self._get_user_friendly_error(e.response.status_code),
|
| 176 |
+
**error_details
|
| 177 |
+
}
|
| 178 |
+
except requests.exceptions.RequestException as e:
|
| 179 |
+
# Other request-related errors
|
| 180 |
+
print(f"MCPClient: General request exception: {e}")
|
| 181 |
+
return {
|
| 182 |
+
'error': f'A network error occurred: {e}',
|
| 183 |
+
'success': False,
|
| 184 |
+
'status_code': None
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
def _get_user_friendly_error(self, status_code: int) -> str:
|
| 188 |
+
"""Get user-friendly error message based on HTTP status code"""
|
| 189 |
+
error_messages = {
|
| 190 |
+
400: "Invalid request - please check your input",
|
| 191 |
+
401: "Authentication required - please log in",
|
| 192 |
+
403: "Access denied",
|
| 193 |
+
404: "Resource not found",
|
| 194 |
+
429: "Please slow down - too many requests",
|
| 195 |
+
500: "Server issue - please try again",
|
| 196 |
+
502: "Server temporarily unavailable",
|
| 197 |
+
503: "Server busy - please try again later"
|
| 198 |
+
}
|
| 199 |
+
return error_messages.get(status_code, f"Error {status_code} occurred")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _get_local_response(self, endpoint: str, method: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
| 204 |
+
"""Provide session-aware local responses for all endpoints"""
|
| 205 |
+
|
| 206 |
+
# Initialize session data if needed
|
| 207 |
+
session_id = self.session_token
|
| 208 |
+
if session_id not in self._mock_sessions:
|
| 209 |
+
self._mock_sessions[session_id] = {
|
| 210 |
+
'emails': self._load_initial_emails(),
|
| 211 |
+
'rules': [],
|
| 212 |
+
'rule_counter': 0
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
session_data = self._mock_sessions[session_id]
|
| 216 |
+
|
| 217 |
+
# Handle different endpoints for local mode
|
| 218 |
+
if '/rules/preview' in endpoint and method == 'POST':
|
| 219 |
+
# Simulate rule preview
|
| 220 |
+
rule = kwargs.get('json', {}).get('rule', {})
|
| 221 |
+
return {
|
| 222 |
+
'success': True,
|
| 223 |
+
'affected_emails': self._get_mock_preview_emails(),
|
| 224 |
+
'statistics': {
|
| 225 |
+
'matched_count': 5,
|
| 226 |
+
'total_scanned': 20
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
elif '/rules/apply' in endpoint and method == 'POST':
|
| 231 |
+
# Simulate rule application
|
| 232 |
+
rule = kwargs.get('json', {}).get('rule', {})
|
| 233 |
+
return {
|
| 234 |
+
'success': True,
|
| 235 |
+
'statistics': {
|
| 236 |
+
'processed_count': 5,
|
| 237 |
+
'moved_count': 3,
|
| 238 |
+
'labeled_count': 2
|
| 239 |
+
},
|
| 240 |
+
'updated_emails': self._get_mock_updated_emails()
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
elif endpoint == '/rules' and method == 'POST':
|
| 244 |
+
# Simulate saving a rule
|
| 245 |
+
rule = kwargs.get('json', {}).get('rule', {})
|
| 246 |
+
rule['rule_id'] = rule.get('rule_id', f'rule_{session_data["rule_counter"]}')
|
| 247 |
+
rule['created_at'] = rule.get('created_at', time.strftime('%Y-%m-%dT%H:%M:%SZ'))
|
| 248 |
+
rule['status'] = rule.get('status', 'pending')
|
| 249 |
+
session_data['rules'].append(rule)
|
| 250 |
+
session_data['rule_counter'] += 1
|
| 251 |
+
return {
|
| 252 |
+
'success': True,
|
| 253 |
+
'rule_id': rule['rule_id'],
|
| 254 |
+
'message': 'Rule saved successfully'
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
elif '/rules' in endpoint and method == 'GET' and 'rule_id' not in endpoint:
|
| 258 |
+
# Return saved rules for this session
|
| 259 |
+
return {
|
| 260 |
+
'rules': session_data['rules'],
|
| 261 |
+
'count': 0,
|
| 262 |
+
'success': True
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
elif '/rules' in endpoint and method == 'PATCH':
|
| 266 |
+
# Handle rule status update
|
| 267 |
+
rule_id = endpoint.split('/')[-1]
|
| 268 |
+
update_data = kwargs.get('json', {}).get('update', {})
|
| 269 |
+
|
| 270 |
+
for rule in session_data['rules']:
|
| 271 |
+
if rule.get('rule_id') == rule_id:
|
| 272 |
+
rule.update(update_data)
|
| 273 |
+
return {
|
| 274 |
+
'success': True,
|
| 275 |
+
'rule': rule,
|
| 276 |
+
'message': f'Rule {rule_id} updated'
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
return {'success': False, 'error': 'Rule not found'}
|
| 280 |
+
|
| 281 |
+
elif '/rules' in endpoint and method == 'DELETE':
|
| 282 |
+
# Simulate rule deletion
|
| 283 |
+
rule_id = endpoint.split('/')[-1]
|
| 284 |
+
session_data['rules'] = [r for r in session_data['rules'] if r.get('rule_id') != rule_id]
|
| 285 |
+
return {
|
| 286 |
+
'success': True,
|
| 287 |
+
'message': 'Rule deleted successfully'
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
elif '/emails/search' in endpoint and method == 'GET':
|
| 291 |
+
# Return search results
|
| 292 |
+
try:
|
| 293 |
+
import json
|
| 294 |
+
with open('data/emails.json', 'r') as f:
|
| 295 |
+
emails = json.load(f)
|
| 296 |
+
query = kwargs.get('params', {}).get('query', '').lower()
|
| 297 |
+
# Simple search simulation
|
| 298 |
+
results = [e for e in emails if query in e.get('subject', '').lower()
|
| 299 |
+
or query in e.get('from_name', '').lower()][:10]
|
| 300 |
+
return {
|
| 301 |
+
'emails': results,
|
| 302 |
+
'success': True
|
| 303 |
+
}
|
| 304 |
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
| 305 |
+
return {'emails': [], 'error': f'Could not load emails: {e}', 'success': False}
|
| 306 |
+
|
| 307 |
+
elif '/emails' in endpoint and method == 'GET':
|
| 308 |
+
# Return emails from session data
|
| 309 |
+
return {
|
| 310 |
+
'emails': session_data['emails'],
|
| 311 |
+
'next_page_token': None,
|
| 312 |
+
'total_estimate': len(session_data['emails']),
|
| 313 |
+
'success': True
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
elif '/labels' in endpoint and method == 'GET':
|
| 317 |
+
# Return mock labels
|
| 318 |
+
return {
|
| 319 |
+
'labels': [
|
| 320 |
+
{'id': 'INBOX', 'name': 'Inbox'},
|
| 321 |
+
{'id': 'WORK', 'name': 'Work'},
|
| 322 |
+
{'id': 'PERSONAL', 'name': 'Personal'},
|
| 323 |
+
{'id': 'ARCHIVE', 'name': 'Archive'}
|
| 324 |
+
],
|
| 325 |
+
'success': True
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
elif '/auth/gmail/url' in endpoint:
|
| 329 |
+
# Return mock OAuth URL
|
| 330 |
+
return {
|
| 331 |
+
'auth_url': 'https://accounts.google.com/oauth/authorize?mock=true',
|
| 332 |
+
'success': True
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
elif '/auth/gmail/callback' in endpoint:
|
| 336 |
+
# Mock OAuth callback
|
| 337 |
+
return {
|
| 338 |
+
'access_token': 'mock_access_token',
|
| 339 |
+
'user_info': {
|
| 340 |
+
'email': '[email protected]',
|
| 341 |
+
'name': 'Demo User'
|
| 342 |
+
},
|
| 343 |
+
'success': True
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
# Default fallback for unknown endpoints
|
| 347 |
+
return {
|
| 348 |
+
'error': f'Endpoint {endpoint} not available in offline mode',
|
| 349 |
+
'success': False,
|
| 350 |
+
'fallback': True
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
def _get_mock_preview_emails(self) -> List[Dict[str, Any]]:
|
| 354 |
+
"""Get mock preview emails for local testing"""
|
| 355 |
+
try:
|
| 356 |
+
import json
|
| 357 |
+
with open('data/preview_emails.json', 'r') as f:
|
| 358 |
+
return json.load(f)[:10]
|
| 359 |
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
| 360 |
+
print(f"MCPClient: Could not load preview emails: {e}")
|
| 361 |
+
return []
|
| 362 |
+
|
| 363 |
+
def _get_mock_updated_emails(self) -> List[Dict[str, Any]]:
|
| 364 |
+
"""Get mock updated emails after rule application"""
|
| 365 |
+
try:
|
| 366 |
+
import json
|
| 367 |
+
with open('data/emails.json', 'r') as f:
|
| 368 |
+
emails = json.load(f)
|
| 369 |
+
# Simulate some emails being moved to different folders
|
| 370 |
+
for i in range(min(5, len(emails))):
|
| 371 |
+
if i % 2 == 0:
|
| 372 |
+
emails[i]['folder'] = 'Work'
|
| 373 |
+
else:
|
| 374 |
+
emails[i]['labels'] = emails[i].get('labels', []) + ['Processed']
|
| 375 |
+
return emails
|
| 376 |
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
| 377 |
+
print(f"MCPClient: Could not load emails for update simulation: {e}")
|
| 378 |
+
return []
|
| 379 |
+
|
| 380 |
+
# Email operations
|
| 381 |
+
|
| 382 |
+
def list_emails(
|
| 383 |
+
self,
|
| 384 |
+
folder: str = 'inbox',
|
| 385 |
+
page_size: int = 20,
|
| 386 |
+
page_token: Optional[str] = None
|
| 387 |
+
) -> Dict[str, Any]:
|
| 388 |
+
"""
|
| 389 |
+
List emails from the specified folder
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
folder: Email folder/label to list
|
| 393 |
+
page_size: Number of emails per page
|
| 394 |
+
page_token: Token for pagination
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
Dict with emails, next_page_token, and total_estimate
|
| 398 |
+
"""
|
| 399 |
+
params = {
|
| 400 |
+
'folder': folder,
|
| 401 |
+
'page_size': page_size,
|
| 402 |
+
'mode': self.mode
|
| 403 |
+
}
|
| 404 |
+
if page_token:
|
| 405 |
+
params['page_token'] = page_token
|
| 406 |
+
|
| 407 |
+
return self._request('GET', '/emails', params=params)
|
| 408 |
+
|
| 409 |
+
def search_emails(
|
| 410 |
+
self,
|
| 411 |
+
query: str,
|
| 412 |
+
folder: Optional[str] = None,
|
| 413 |
+
max_results: int = 50
|
| 414 |
+
) -> List[Dict[str, Any]]:
|
| 415 |
+
"""
|
| 416 |
+
Search emails with the given query
|
| 417 |
+
|
| 418 |
+
Args:
|
| 419 |
+
query: Search query
|
| 420 |
+
folder: Optional folder to search within
|
| 421 |
+
max_results: Maximum number of results
|
| 422 |
+
|
| 423 |
+
Returns:
|
| 424 |
+
List of matching emails
|
| 425 |
+
"""
|
| 426 |
+
params = {
|
| 427 |
+
'search': query, # Changed from 'query' to 'search'
|
| 428 |
+
'max_results': max_results,
|
| 429 |
+
'mode': self.mode
|
| 430 |
+
}
|
| 431 |
+
if folder:
|
| 432 |
+
params['folder'] = folder
|
| 433 |
+
|
| 434 |
+
response = self._request('GET', '/emails', params=params) # Changed endpoint
|
| 435 |
+
return response.get('emails', [])
|
| 436 |
+
|
| 437 |
+
def get_labels(self) -> List[Dict[str, Any]]:
|
| 438 |
+
"""
|
| 439 |
+
Get available email labels/folders
|
| 440 |
+
|
| 441 |
+
Returns:
|
| 442 |
+
List of label dictionaries
|
| 443 |
+
"""
|
| 444 |
+
response = self._request('GET', '/labels', params={'mode': self.mode})
|
| 445 |
+
return response.get('labels', [])
|
| 446 |
+
|
| 447 |
+
# Rule operations
|
| 448 |
+
|
| 449 |
+
def preview_rule(
|
| 450 |
+
self,
|
| 451 |
+
rule: Dict[str, Any],
|
| 452 |
+
sample_size: int = 10
|
| 453 |
+
) -> Dict[str, Any]:
|
| 454 |
+
"""
|
| 455 |
+
Preview the effects of a rule without applying it
|
| 456 |
+
|
| 457 |
+
Args:
|
| 458 |
+
rule: Rule dictionary
|
| 459 |
+
sample_size: Number of sample emails to show
|
| 460 |
+
|
| 461 |
+
Returns:
|
| 462 |
+
Dict with matched emails and statistics
|
| 463 |
+
"""
|
| 464 |
+
return self._request('POST', '/rules/preview', json={
|
| 465 |
+
'rule': rule,
|
| 466 |
+
'sample_size': sample_size,
|
| 467 |
+
'mode': self.mode
|
| 468 |
+
})
|
| 469 |
+
|
| 470 |
+
def apply_rule(
|
| 471 |
+
self,
|
| 472 |
+
rule: Dict[str, Any],
|
| 473 |
+
preview: bool = False
|
| 474 |
+
) -> Dict[str, Any]:
|
| 475 |
+
"""
|
| 476 |
+
Apply a rule to emails
|
| 477 |
+
|
| 478 |
+
Args:
|
| 479 |
+
rule: Rule dictionary
|
| 480 |
+
preview: If True, only preview without applying
|
| 481 |
+
|
| 482 |
+
Returns:
|
| 483 |
+
Dict with results
|
| 484 |
+
"""
|
| 485 |
+
return self._request('POST', '/rules/apply', json={
|
| 486 |
+
'rule': rule,
|
| 487 |
+
'preview': preview,
|
| 488 |
+
'mode': self.mode
|
| 489 |
+
})
|
| 490 |
+
|
| 491 |
+
def save_rule(self, rule: Dict[str, Any]) -> Dict[str, Any]:
|
| 492 |
+
"""
|
| 493 |
+
Save a rule for the current user/session
|
| 494 |
+
|
| 495 |
+
Args:
|
| 496 |
+
rule: Rule dictionary to save
|
| 497 |
+
|
| 498 |
+
Returns:
|
| 499 |
+
Dict with saved rule ID
|
| 500 |
+
"""
|
| 501 |
+
return self._request('POST', '/rules/save', json={
|
| 502 |
+
'rule': rule,
|
| 503 |
+
'mode': self.mode
|
| 504 |
+
})
|
| 505 |
+
|
| 506 |
+
def update_rule_status(self, rule_id: str, new_status: str) -> Dict[str, Any]:
|
| 507 |
+
"""Update the status of a rule"""
|
| 508 |
+
return self._request('PATCH', f'/rules/{rule_id}', json={
|
| 509 |
+
'update': {'status': new_status}
|
| 510 |
+
})
|
| 511 |
+
|
| 512 |
+
def archive_rule(self, rule_id: str) -> Dict[str, Any]:
|
| 513 |
+
"""Archive (reject) a rule"""
|
| 514 |
+
return self.update_rule_status(rule_id, 'rejected')
|
| 515 |
+
|
| 516 |
+
def reactivate_rule(self, rule_id: str) -> Dict[str, Any]:
|
| 517 |
+
"""Reactivate an archived rule"""
|
| 518 |
+
return self.update_rule_status(rule_id, 'pending')
|
| 519 |
+
|
| 520 |
+
def get_rules(self) -> List[Dict[str, Any]]:
|
| 521 |
+
"""
|
| 522 |
+
Get saved rules for the current user/session
|
| 523 |
+
|
| 524 |
+
Returns:
|
| 525 |
+
List of saved rules
|
| 526 |
+
"""
|
| 527 |
+
response = self._request('GET', '/rules', params={'mode': self.mode})
|
| 528 |
+
return response.get('rules', [])
|
| 529 |
+
|
| 530 |
+
def delete_rule(self, rule_id: str) -> Dict[str, Any]:
|
| 531 |
+
"""
|
| 532 |
+
Delete a saved rule
|
| 533 |
+
|
| 534 |
+
Args:
|
| 535 |
+
rule_id: ID of the rule to delete
|
| 536 |
+
|
| 537 |
+
Returns:
|
| 538 |
+
Dict with success status
|
| 539 |
+
"""
|
| 540 |
+
return self._request('DELETE', f'/rules/{rule_id}')
|
| 541 |
+
|
| 542 |
+
# Authentication
|
| 543 |
+
|
| 544 |
+
def get_gmail_auth_url(self) -> str:
|
| 545 |
+
"""
|
| 546 |
+
Get Gmail OAuth authorization URL
|
| 547 |
+
|
| 548 |
+
Returns:
|
| 549 |
+
OAuth URL for Gmail authorization
|
| 550 |
+
"""
|
| 551 |
+
response = self._request('GET', '/auth/gmail/url')
|
| 552 |
+
return response.get('auth_url', '')
|
| 553 |
+
|
| 554 |
+
def exchange_gmail_code(self, code: str) -> Dict[str, Any]:
|
| 555 |
+
"""
|
| 556 |
+
Exchange Gmail OAuth code for tokens
|
| 557 |
+
|
| 558 |
+
Args:
|
| 559 |
+
code: OAuth authorization code
|
| 560 |
+
|
| 561 |
+
Returns:
|
| 562 |
+
Dict with access token and user info
|
| 563 |
+
"""
|
| 564 |
+
return self._request('POST', '/auth/gmail/callback', json={'code': code})
|
| 565 |
+
|
| 566 |
+
def _load_initial_emails(self) -> List[Dict[str, Any]]:
|
| 567 |
+
"""Load initial emails from file for demo mode"""
|
| 568 |
+
try:
|
| 569 |
+
import json
|
| 570 |
+
with open('data/emails.json', 'r') as f:
|
| 571 |
+
return json.load(f)
|
| 572 |
+
except (FileNotFoundError, json.JSONDecodeError) as e:
|
| 573 |
+
print(f"Warning: Could not load initial emails: {e}")
|
| 574 |
+
# Return some default demo emails
|
| 575 |
+
return [
|
| 576 |
+
{
|
| 577 |
+
"id": "msg_001",
|
| 578 |
+
"from_email": "[email protected]",
|
| 579 |
+
"from_name": "Demo User",
|
| 580 |
+
"subject": "Welcome to Email Rule Agent!",
|
| 581 |
+
"snippet": "This is a demo email to get you started...",
|
| 582 |
+
"body": "This is a demo email to get you started with the Email Rule Agent.",
|
| 583 |
+
"date": "2024-01-15T10:00:00Z",
|
| 584 |
+
"labels": ["INBOX"],
|
| 585 |
+
"folder": "INBOX"
|
| 586 |
+
}
|
| 587 |
+
]
|
components/models.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for email rules to ensure consistent JSON structure from LLM."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
from typing import List, Optional, Literal, Union
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class RuleCondition(BaseModel):
|
| 8 |
+
"""Represents a condition for matching emails."""
|
| 9 |
+
field: Literal["from", "subject", "body"]
|
| 10 |
+
operator: Literal["contains", "equals", "starts_with"]
|
| 11 |
+
value: Union[str, List[str]]
|
| 12 |
+
case_sensitive: bool = False
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class RuleParameters(BaseModel):
|
| 16 |
+
"""Parameters for rule actions."""
|
| 17 |
+
folder: Optional[str] = None
|
| 18 |
+
label: Optional[str] = None
|
| 19 |
+
template: Optional[str] = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RuleAction(BaseModel):
|
| 23 |
+
"""Represents an action to take on matched emails."""
|
| 24 |
+
type: Literal["move", "archive", "label", "draft"]
|
| 25 |
+
parameters: RuleParameters
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class EmailRule(BaseModel):
|
| 29 |
+
"""Complete email rule with conditions and actions."""
|
| 30 |
+
rule_id: str = Field(description="Unique identifier for the rule")
|
| 31 |
+
name: str = Field(description="Human-readable name for the rule")
|
| 32 |
+
description: str = Field(description="Detailed description of what the rule does")
|
| 33 |
+
conditions: List[RuleCondition] = Field(description="List of conditions that must be met")
|
| 34 |
+
actions: List[RuleAction] = Field(description="List of actions to perform")
|
| 35 |
+
confidence: float = Field(ge=0.0, le=1.0, description="Confidence score for the rule")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class EmailRuleList(BaseModel):
|
| 39 |
+
"""List of email rules for bulk analysis."""
|
| 40 |
+
rules: List[EmailRule]
|
components/session_manager.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Session management for multi-user support
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import uuid
|
| 6 |
+
from typing import Dict, List, Any, Optional
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
class SessionManager:
|
| 12 |
+
"""Manages user sessions with automatic cleanup"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, session_timeout_minutes: int = 60):
|
| 15 |
+
self.sessions: Dict[str, Dict[str, Any]] = {}
|
| 16 |
+
self.session_timeout = timedelta(minutes=session_timeout_minutes)
|
| 17 |
+
self.lock = threading.Lock()
|
| 18 |
+
|
| 19 |
+
# Start cleanup thread
|
| 20 |
+
self.cleanup_thread = threading.Thread(target=self._cleanup_expired_sessions, daemon=True)
|
| 21 |
+
self.cleanup_thread.start()
|
| 22 |
+
|
| 23 |
+
def create_session(self) -> str:
|
| 24 |
+
"""Create a new session and return session ID"""
|
| 25 |
+
session_id = str(uuid.uuid4())
|
| 26 |
+
|
| 27 |
+
with self.lock:
|
| 28 |
+
self.sessions[session_id] = {
|
| 29 |
+
'created_at': datetime.now(),
|
| 30 |
+
'last_accessed': datetime.now(),
|
| 31 |
+
'pending_rules': [],
|
| 32 |
+
'applied_rules': [],
|
| 33 |
+
'rule_counter': 0,
|
| 34 |
+
'conversation_history': [],
|
| 35 |
+
'email_provider': 'mock', # 'mock' or 'gmail'
|
| 36 |
+
'gmail_token': None
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return session_id
|
| 40 |
+
|
| 41 |
+
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 42 |
+
"""Get session data by ID"""
|
| 43 |
+
with self.lock:
|
| 44 |
+
if session_id in self.sessions:
|
| 45 |
+
# Update last accessed time
|
| 46 |
+
self.sessions[session_id]['last_accessed'] = datetime.now()
|
| 47 |
+
return self.sessions[session_id]
|
| 48 |
+
return None
|
| 49 |
+
|
| 50 |
+
def update_session(self, session_id: str, updates: Dict[str, Any]) -> bool:
|
| 51 |
+
"""Update session data"""
|
| 52 |
+
with self.lock:
|
| 53 |
+
if session_id in self.sessions:
|
| 54 |
+
self.sessions[session_id].update(updates)
|
| 55 |
+
self.sessions[session_id]['last_accessed'] = datetime.now()
|
| 56 |
+
return True
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
def add_rule(self, session_id: str, rule: Dict[str, Any]) -> bool:
|
| 60 |
+
"""Add a rule to session's pending rules"""
|
| 61 |
+
with self.lock:
|
| 62 |
+
if session_id in self.sessions:
|
| 63 |
+
session = self.sessions[session_id]
|
| 64 |
+
rule['id'] = f"rule_{session['rule_counter']}"
|
| 65 |
+
session['pending_rules'].append(rule)
|
| 66 |
+
session['rule_counter'] += 1
|
| 67 |
+
session['last_accessed'] = datetime.now()
|
| 68 |
+
return True
|
| 69 |
+
return False
|
| 70 |
+
|
| 71 |
+
def get_rules(self, session_id: str) -> Dict[str, List[Dict[str, Any]]]:
|
| 72 |
+
"""Get all rules for a session"""
|
| 73 |
+
with self.lock:
|
| 74 |
+
if session_id in self.sessions:
|
| 75 |
+
session = self.sessions[session_id]
|
| 76 |
+
return {
|
| 77 |
+
'pending_rules': session['pending_rules'],
|
| 78 |
+
'applied_rules': session['applied_rules']
|
| 79 |
+
}
|
| 80 |
+
return {'pending_rules': [], 'applied_rules': []}
|
| 81 |
+
|
| 82 |
+
def apply_rule(self, session_id: str, rule_id: str) -> bool:
|
| 83 |
+
"""Move a rule from pending to applied"""
|
| 84 |
+
with self.lock:
|
| 85 |
+
if session_id in self.sessions:
|
| 86 |
+
session = self.sessions[session_id]
|
| 87 |
+
for i, rule in enumerate(session['pending_rules']):
|
| 88 |
+
if rule['id'] == rule_id:
|
| 89 |
+
rule['status'] = 'applied'
|
| 90 |
+
session['applied_rules'].append(rule)
|
| 91 |
+
session['pending_rules'].pop(i)
|
| 92 |
+
session['last_accessed'] = datetime.now()
|
| 93 |
+
return True
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
def _cleanup_expired_sessions(self):
|
| 97 |
+
"""Background thread to clean up expired sessions"""
|
| 98 |
+
while True:
|
| 99 |
+
time.sleep(300) # Check every 5 minutes
|
| 100 |
+
|
| 101 |
+
with self.lock:
|
| 102 |
+
now = datetime.now()
|
| 103 |
+
expired_sessions = []
|
| 104 |
+
|
| 105 |
+
for session_id, session_data in self.sessions.items():
|
| 106 |
+
if now - session_data['last_accessed'] > self.session_timeout:
|
| 107 |
+
expired_sessions.append(session_id)
|
| 108 |
+
|
| 109 |
+
for session_id in expired_sessions:
|
| 110 |
+
del self.sessions[session_id]
|
| 111 |
+
print(f"Cleaned up expired session: {session_id}")
|
| 112 |
+
|
| 113 |
+
# Global session manager instance
|
| 114 |
+
session_manager = SessionManager()
|
components/simple_agent.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Simple agent implementation without CrewAI overhead
|
| 3 |
+
Now with natural language rule extraction!
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import re
|
| 9 |
+
import logging
|
| 10 |
+
import uuid
|
| 11 |
+
from typing import List, Dict, Any, Optional, Callable, Tuple
|
| 12 |
+
from langchain_openai import ChatOpenAI
|
| 13 |
+
from langchain.schema import HumanMessage, SystemMessage, AIMessage
|
| 14 |
+
from .models import EmailRule, EmailRuleList, RuleCondition, RuleAction, RuleParameters
|
| 15 |
+
|
| 16 |
+
# Set up logging
|
| 17 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# Global email context
|
| 21 |
+
email_context = []
|
| 22 |
+
|
| 23 |
+
def get_llm(use_thinking=False):
|
| 24 |
+
"""Get configured OpenRouter LLM"""
|
| 25 |
+
api_key = os.getenv('OPENROUTER_API_KEY')
|
| 26 |
+
|
| 27 |
+
if not api_key:
|
| 28 |
+
raise ValueError(
|
| 29 |
+
"OpenRouter API key not configured. "
|
| 30 |
+
"Please set OPENROUTER_API_KEY in HuggingFace Spaces secrets."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# Use thinking model for complex reasoning tasks
|
| 34 |
+
model = 'google/gemini-2.5-flash-preview-05-20:thinking' if use_thinking else 'google/gemini-2.5-flash-preview-05-20'
|
| 35 |
+
|
| 36 |
+
return ChatOpenAI(
|
| 37 |
+
openai_api_key=api_key,
|
| 38 |
+
openai_api_base="https://openrouter.ai/api/v1",
|
| 39 |
+
model_name=model,
|
| 40 |
+
temperature=0.7,
|
| 41 |
+
streaming=True,
|
| 42 |
+
default_headers={
|
| 43 |
+
"HTTP-Referer": os.getenv('HF_SPACE_URL', 'http://localhost:7860'),
|
| 44 |
+
"X-Title": "Email Rule Agent"
|
| 45 |
+
}
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def analyze_emails_and_propose_rules(emails: List[Dict[str, Any]], focus_area: str = "all types") -> Dict[str, Any]:
|
| 50 |
+
"""Analyze emails and propose organization rules"""
|
| 51 |
+
|
| 52 |
+
if not emails:
|
| 53 |
+
return {
|
| 54 |
+
'success': False,
|
| 55 |
+
'message': "No emails available to analyze.",
|
| 56 |
+
'rules': []
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# Prepare email summaries
|
| 60 |
+
email_summaries = []
|
| 61 |
+
for e in emails[:30]: # Limit to 30 emails
|
| 62 |
+
email_summaries.append({
|
| 63 |
+
'from': e.get('from_name', ''),
|
| 64 |
+
'subject': e.get('subject', ''),
|
| 65 |
+
'preview': (e.get('body', '')[:150] + '...') if len(e.get('body', '')) > 150 else e.get('body', '')
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
prompt = """Analyze these """ + str(len(email_summaries)) + """ email samples and propose 3-5 organization rules.
|
| 69 |
+
|
| 70 |
+
Focus area: """ + focus_area + """
|
| 71 |
+
|
| 72 |
+
Available folders: inbox, reading, work, archive
|
| 73 |
+
You can suggest new folders if needed.
|
| 74 |
+
|
| 75 |
+
Email samples:
|
| 76 |
+
""" + json.dumps(email_summaries, indent=2) + """
|
| 77 |
+
|
| 78 |
+
Look for patterns like:
|
| 79 |
+
- Newsletters (subject contains "newsletter", from contains "news")
|
| 80 |
+
- Work emails (from specific domains)
|
| 81 |
+
- Social media notifications
|
| 82 |
+
- Marketing/promotional emails
|
| 83 |
+
- Personal emails from friends/family
|
| 84 |
+
|
| 85 |
+
Create practical rules with clear patterns.
|
| 86 |
+
|
| 87 |
+
Each condition MUST have a non-empty value extracted from the email samples.
|
| 88 |
+
|
| 89 |
+
Example good rule:
|
| 90 |
+
{{
|
| 91 |
+
"name": "Tech Newsletters",
|
| 92 |
+
"conditions": [
|
| 93 |
+
{{"field": "from", "operator": "contains", "value": "techcrunch"}}
|
| 94 |
+
],
|
| 95 |
+
"actions": [{{"type": "move", "parameters": {{"folder": "reading"}}}}]
|
| 96 |
+
}}
|
| 97 |
+
|
| 98 |
+
DO NOT create rules with empty values like "" in conditions.
|
| 99 |
+
|
| 100 |
+
Return a JSON object with a 'rules' array containing the email rules."""
|
| 101 |
+
|
| 102 |
+
try:
|
| 103 |
+
llm = get_llm(use_thinking=False)
|
| 104 |
+
|
| 105 |
+
# Try to use structured output if available
|
| 106 |
+
try:
|
| 107 |
+
structured_llm = llm.with_structured_output(EmailRuleList)
|
| 108 |
+
response = structured_llm.invoke([
|
| 109 |
+
SystemMessage(content="You are an email pattern analyzer. Create 3-5 practical organization rules based on the email patterns you observe."),
|
| 110 |
+
HumanMessage(content=prompt)
|
| 111 |
+
])
|
| 112 |
+
|
| 113 |
+
# Response is an EmailRuleList object
|
| 114 |
+
formatted_rules = []
|
| 115 |
+
for rule in response.rules:
|
| 116 |
+
formatted_rule = rule.model_dump()
|
| 117 |
+
# Ensure rule_id is set with UUID
|
| 118 |
+
if not formatted_rule.get('rule_id'):
|
| 119 |
+
formatted_rule['rule_id'] = f"rule_{uuid.uuid4().hex[:8]}"
|
| 120 |
+
formatted_rules.append(formatted_rule)
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
'success': True,
|
| 124 |
+
'rules': formatted_rules,
|
| 125 |
+
'message': f"Found {len(formatted_rules)} patterns in your emails!"
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
except Exception as struct_error:
|
| 129 |
+
# Fallback to regex extraction if structured output not supported
|
| 130 |
+
logger.warning(f"Structured output not available, falling back to regex: {struct_error}")
|
| 131 |
+
|
| 132 |
+
response = llm.invoke([
|
| 133 |
+
SystemMessage(content="You are an email pattern analyzer. Always respond with valid JSON. Extract actual values from the email content - never use empty strings."),
|
| 134 |
+
HumanMessage(content=prompt)
|
| 135 |
+
])
|
| 136 |
+
|
| 137 |
+
# Extract JSON from response
|
| 138 |
+
response_text = response.content
|
| 139 |
+
json_match = re.search(r'\{[\s\S]*\}', response_text)
|
| 140 |
+
|
| 141 |
+
if json_match:
|
| 142 |
+
parsed = json.loads(json_match.group())
|
| 143 |
+
rules = parsed.get('rules', [])
|
| 144 |
+
|
| 145 |
+
# Format rules for the UI
|
| 146 |
+
formatted_rules = []
|
| 147 |
+
for rule in rules:
|
| 148 |
+
formatted_rule = {
|
| 149 |
+
"name": rule.get('name', 'Unnamed Rule'),
|
| 150 |
+
"description": rule.get('description', 'Automatically organize emails'),
|
| 151 |
+
"conditions": [{
|
| 152 |
+
"field": c.get('field', 'from'),
|
| 153 |
+
"operator": c.get('operator', 'contains'),
|
| 154 |
+
"value": c.get('value', ''),
|
| 155 |
+
"case_sensitive": False
|
| 156 |
+
} for c in rule.get('conditions', []) if c.get('value') and (isinstance(c.get('value'), str) and c.get('value').strip() or isinstance(c.get('value'), list) and c.get('value'))],
|
| 157 |
+
"actions": [{
|
| 158 |
+
"type": a.get('type', 'move'),
|
| 159 |
+
"parameters": a.get('parameters', {
|
| 160 |
+
"folder": a.get('folder', 'inbox'),
|
| 161 |
+
"label": a.get('label', ''),
|
| 162 |
+
"template": a.get('template', '')
|
| 163 |
+
})
|
| 164 |
+
} for a in rule.get('actions', [])],
|
| 165 |
+
"confidence": rule.get('confidence', 0.8),
|
| 166 |
+
"rule_id": f"rule_{uuid.uuid4().hex[:8]}"
|
| 167 |
+
}
|
| 168 |
+
formatted_rules.append(formatted_rule)
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
'success': True,
|
| 172 |
+
'rules': formatted_rules,
|
| 173 |
+
'message': f"Found {len(formatted_rules)} patterns in your emails!"
|
| 174 |
+
}
|
| 175 |
+
else:
|
| 176 |
+
return {
|
| 177 |
+
'success': False,
|
| 178 |
+
'message': "Could not parse rule suggestions.",
|
| 179 |
+
'rules': []
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
except Exception as e:
|
| 183 |
+
print(f"Error analyzing emails: {e}")
|
| 184 |
+
return {
|
| 185 |
+
'success': False,
|
| 186 |
+
'message': f"Error analyzing emails: {str(e)}",
|
| 187 |
+
'rules': []
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def detect_rule_intent(message: str) -> bool:
|
| 192 |
+
"""Check if the message contains intent to create a rule"""
|
| 193 |
+
rule_indicators = [
|
| 194 |
+
# Direct commands
|
| 195 |
+
r'\b(move|archive|label|organize|put|send|flag|mark)\b.*\b(all|these|those|emails?|messages?)\b',
|
| 196 |
+
r'\b(i want|i need|please|can you|could you)\b.*\b(move|archive|organize)',
|
| 197 |
+
r'\ball\s+(my\s+)?(newsletter|marketing|promotional|work|personal)',
|
| 198 |
+
r'\b(delete|remove|trash)\b.*\b(all|these|emails)', # Will convert to archive
|
| 199 |
+
|
| 200 |
+
# Draft patterns
|
| 201 |
+
r'\b(draft|reply|respond)\b.*\b(polite|professional|saying|acknowledging)\b',
|
| 202 |
+
r'\b(send|write|create)\b.*\b(reply|response|acknowledgment)\b',
|
| 203 |
+
r'\b(draft|create|write)\b.*\b(replies?|responses?)\b.*\b(for|to)\b',
|
| 204 |
+
r'\bfor\s+(emails?\s+)?from\s+.*\bdraft\b',
|
| 205 |
+
r'\backnowledge\b.*\b(emails?|messages?)\b',
|
| 206 |
+
|
| 207 |
+
# Specific patterns
|
| 208 |
+
r'emails?\s+from\s+\w+',
|
| 209 |
+
r'put\s+.*\s+in(to)?\s+',
|
| 210 |
+
r'archive\s+(all\s+)?.*emails?',
|
| 211 |
+
r'unsubscribe|spam|junk',
|
| 212 |
+
]
|
| 213 |
+
|
| 214 |
+
message_lower = message.lower()
|
| 215 |
+
return any(re.search(pattern, message_lower) for pattern in rule_indicators)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def resolve_context_references(
|
| 219 |
+
text: str,
|
| 220 |
+
conversation_history: List[Dict],
|
| 221 |
+
emails: List[Dict[str, Any]]
|
| 222 |
+
) -> Tuple[str, Dict[str, Any]]:
|
| 223 |
+
"""Resolve contextual references like 'them', 'those' using conversation history"""
|
| 224 |
+
|
| 225 |
+
context_info = {
|
| 226 |
+
'mentioned_senders': [],
|
| 227 |
+
'mentioned_categories': [],
|
| 228 |
+
'mentioned_subjects': []
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
# Look for recent context in conversation
|
| 232 |
+
for msg in conversation_history[-5:]: # Last 5 messages
|
| 233 |
+
content = msg.get('content', '').lower()
|
| 234 |
+
|
| 235 |
+
# Check for email references
|
| 236 |
+
if 'newsletter' in content:
|
| 237 |
+
context_info['mentioned_categories'].append('newsletter')
|
| 238 |
+
if 'marketing' in content or 'promotional' in content:
|
| 239 |
+
context_info['mentioned_categories'].append('marketing')
|
| 240 |
+
if 'work' in content or 'colleague' in content:
|
| 241 |
+
context_info['mentioned_categories'].append('work')
|
| 242 |
+
|
| 243 |
+
# Extract email addresses mentioned
|
| 244 |
+
email_pattern = r'[\w\.-]+@[\w\.-]+\.\w+'
|
| 245 |
+
found_emails = re.findall(email_pattern, content)
|
| 246 |
+
context_info['mentioned_senders'].extend(found_emails)
|
| 247 |
+
|
| 248 |
+
# Replace contextual references
|
| 249 |
+
resolved_text = text
|
| 250 |
+
if re.search(r'\b(them|those|these)\b', text.lower()) and not re.search(r'(emails?|messages?)', text.lower()):
|
| 251 |
+
# Add clarification based on context
|
| 252 |
+
if context_info['mentioned_categories']:
|
| 253 |
+
category = context_info['mentioned_categories'][-1]
|
| 254 |
+
resolved_text = text.replace('them', f'{category} emails')
|
| 255 |
+
resolved_text = resolved_text.replace('those', f'those {category} emails')
|
| 256 |
+
resolved_text = resolved_text.replace('these', f'these {category} emails')
|
| 257 |
+
|
| 258 |
+
return resolved_text, context_info
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def extract_rule_from_natural_language(
|
| 262 |
+
user_message: str,
|
| 263 |
+
conversation_history: List[Dict],
|
| 264 |
+
emails: List[Dict[str, Any]]
|
| 265 |
+
) -> Optional[Dict[str, Any]]:
|
| 266 |
+
"""Extract a rule from natural language request"""
|
| 267 |
+
|
| 268 |
+
# Resolve context
|
| 269 |
+
resolved_message, context_info = resolve_context_references(
|
| 270 |
+
user_message, conversation_history, emails
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Convert delete to archive
|
| 274 |
+
if re.search(r'\b(delete|remove|trash)\b', resolved_message.lower()):
|
| 275 |
+
resolved_message = re.sub(
|
| 276 |
+
r'\b(delete|remove|trash)\b',
|
| 277 |
+
'archive',
|
| 278 |
+
resolved_message,
|
| 279 |
+
flags=re.IGNORECASE
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
# Use LLM to extract rule components
|
| 283 |
+
llm = get_llm(use_thinking=False)
|
| 284 |
+
|
| 285 |
+
# Include recent conversation context
|
| 286 |
+
recent_context = ""
|
| 287 |
+
if conversation_history and len(conversation_history) > 0:
|
| 288 |
+
# Get last 2 exchanges
|
| 289 |
+
recent = conversation_history[-4:] if len(conversation_history) >= 4 else conversation_history
|
| 290 |
+
for msg in recent:
|
| 291 |
+
role = "User" if msg.get('role') == 'user' else "Assistant"
|
| 292 |
+
recent_context += f"{role}: {msg.get('content', '')}\n"
|
| 293 |
+
|
| 294 |
+
prompt = f"""Extract email rule components from this request: "{resolved_message}"
|
| 295 |
+
|
| 296 |
+
Recent conversation:
|
| 297 |
+
{recent_context}
|
| 298 |
+
|
| 299 |
+
Context from conversation:
|
| 300 |
+
- Mentioned categories: {context_info['mentioned_categories']}
|
| 301 |
+
- Mentioned senders: {context_info['mentioned_senders']}
|
| 302 |
+
|
| 303 |
+
IMPORTANT: If this is NOT a request to create, modify, or apply an email rule, return the word "NOTARULE" instead of JSON.
|
| 304 |
+
|
| 305 |
+
Common rule requests include:
|
| 306 |
+
- "Move [emails] to [folder]"
|
| 307 |
+
- "Archive [type of emails]"
|
| 308 |
+
- "Please move all Uber receipts to travel folder"
|
| 309 |
+
- Confirmations like "yes", "ok", "sure" after discussing a rule
|
| 310 |
+
|
| 311 |
+
Email rule requests typically:
|
| 312 |
+
- Ask to move, archive, label, organize, or draft replies for emails
|
| 313 |
+
- Mention email senders, subjects, or content patterns
|
| 314 |
+
- Describe actions to take on emails
|
| 315 |
+
|
| 316 |
+
Available actions: move (to folder), archive, label, draft (a reply)
|
| 317 |
+
Available folders: inbox, reading, work, personal, receipts, travel, archive
|
| 318 |
+
|
| 319 |
+
If the user is confirming a rule (saying "yes", "ok", "sure" after you offered to create one), extract the rule from the conversation.
|
| 320 |
+
|
| 321 |
+
Return JSON in this EXACT format:
|
| 322 |
+
{{
|
| 323 |
+
"name": "Short descriptive name for the rule",
|
| 324 |
+
"description": "What this rule does",
|
| 325 |
+
"conditions": [
|
| 326 |
+
{{"field": "from", "operator": "contains", "value": "uber"}}
|
| 327 |
+
],
|
| 328 |
+
"actions": [
|
| 329 |
+
{{"type": "move", "parameters": {{"folder": "travel"}}}}
|
| 330 |
+
],
|
| 331 |
+
"confidence": 0.9
|
| 332 |
+
}}
|
| 333 |
+
|
| 334 |
+
Examples:
|
| 335 |
+
- "Move emails from Uber to travel folder" → {{"name": "Uber to Travel", "conditions": [{{"field": "from", "operator": "contains", "value": "uber"}}], "actions": [{{"type": "move", "parameters": {{"folder": "travel"}}}}]}}
|
| 336 |
+
- User says "Yes" after "Shall I move Uber receipts to travel?" → Extract the Uber rule from context"""
|
| 337 |
+
|
| 338 |
+
try:
|
| 339 |
+
# Try to use structured output
|
| 340 |
+
try:
|
| 341 |
+
structured_llm = llm.with_structured_output(EmailRule)
|
| 342 |
+
response = structured_llm.invoke([
|
| 343 |
+
SystemMessage(content="You are a rule extraction expert. Extract email rule components from the user's request."),
|
| 344 |
+
HumanMessage(content=prompt)
|
| 345 |
+
])
|
| 346 |
+
|
| 347 |
+
# Response is an EmailRule object
|
| 348 |
+
formatted_rule = response.model_dump()
|
| 349 |
+
|
| 350 |
+
# Ensure rule_id is set with UUID
|
| 351 |
+
if not formatted_rule.get('rule_id'):
|
| 352 |
+
formatted_rule['rule_id'] = f"rule_{uuid.uuid4().hex[:8]}"
|
| 353 |
+
|
| 354 |
+
return formatted_rule
|
| 355 |
+
|
| 356 |
+
except Exception as struct_error:
|
| 357 |
+
# Fallback to regex extraction
|
| 358 |
+
logger.warning(f"Structured output not available for single rule, falling back to regex: {struct_error}")
|
| 359 |
+
|
| 360 |
+
response = llm.invoke([
|
| 361 |
+
SystemMessage(content="You are a rule extraction expert. Return either valid JSON for a rule or 'NOTARULE' if this is not a rule request."),
|
| 362 |
+
HumanMessage(content=prompt)
|
| 363 |
+
])
|
| 364 |
+
|
| 365 |
+
# Check if it's not a rule
|
| 366 |
+
if "NOTARULE" in response.content:
|
| 367 |
+
return None
|
| 368 |
+
|
| 369 |
+
# Extract JSON
|
| 370 |
+
json_match = re.search(r'\{[\s\S]*\}', response.content)
|
| 371 |
+
if json_match:
|
| 372 |
+
rule_data = json.loads(json_match.group())
|
| 373 |
+
logger.info(f"LLM returned JSON: {rule_data}")
|
| 374 |
+
|
| 375 |
+
# Format for UI - with defaults for missing fields
|
| 376 |
+
formatted_rule = {
|
| 377 |
+
"name": rule_data.get('name', 'Move Uber receipts to Travel'),
|
| 378 |
+
"description": rule_data.get('description', 'Automatically organize Uber receipts'),
|
| 379 |
+
"conditions": [{
|
| 380 |
+
"field": c.get('field', 'from'),
|
| 381 |
+
"operator": c.get('operator', 'contains'),
|
| 382 |
+
"value": c.get('value', ''),
|
| 383 |
+
"case_sensitive": False
|
| 384 |
+
} for c in rule_data.get('conditions', [])],
|
| 385 |
+
"actions": [{
|
| 386 |
+
"type": a.get('type', 'move'),
|
| 387 |
+
"parameters": a.get('parameters', {
|
| 388 |
+
"folder": a.get('folder', 'inbox'),
|
| 389 |
+
"label": a.get('label', ''),
|
| 390 |
+
"template": a.get('template', '')
|
| 391 |
+
})
|
| 392 |
+
} for a in rule_data.get('actions', [])],
|
| 393 |
+
"confidence": rule_data.get('confidence', 0.9),
|
| 394 |
+
"rule_id": f"rule_{uuid.uuid4().hex[:8]}"
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
return formatted_rule
|
| 398 |
+
|
| 399 |
+
except Exception as e:
|
| 400 |
+
logger.error(f"Error extracting rule: {e}")
|
| 401 |
+
logger.error(f"Response content was: {response.content if 'response' in locals() else 'No response'}")
|
| 402 |
+
|
| 403 |
+
return None
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def process_chat_message(
|
| 407 |
+
user_message: str,
|
| 408 |
+
emails: List[Dict[str, Any]],
|
| 409 |
+
conversation_history: Optional[List[Dict]] = None,
|
| 410 |
+
callback: Optional[Callable] = None,
|
| 411 |
+
rule_state: Optional[Dict] = None
|
| 412 |
+
) -> Dict[str, Any]:
|
| 413 |
+
"""Process a user message with improved priority flow"""
|
| 414 |
+
logger.info(f"=== AGENT CALLED ===\nMessage: {user_message[:50]}...\nEmails: {len(emails) if emails else 0}")
|
| 415 |
+
|
| 416 |
+
global email_context
|
| 417 |
+
email_context = emails
|
| 418 |
+
|
| 419 |
+
if not conversation_history:
|
| 420 |
+
conversation_history = []
|
| 421 |
+
|
| 422 |
+
try:
|
| 423 |
+
# PRIORITY 1: Always try to extract rule from natural language first
|
| 424 |
+
logger.info("Checking if this is a rule request...")
|
| 425 |
+
|
| 426 |
+
# Show thinking indicator
|
| 427 |
+
if callback:
|
| 428 |
+
callback({'type': 'analyzer_feedback', 'content': '🤔 Understanding your request...'})
|
| 429 |
+
|
| 430 |
+
# Extract rule from natural language - let Gemini decide if it's a rule
|
| 431 |
+
extracted_rule = extract_rule_from_natural_language(
|
| 432 |
+
user_message, conversation_history, emails
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
if extracted_rule:
|
| 436 |
+
logger.info(f"Rule extracted successfully: {extracted_rule}")
|
| 437 |
+
# Check if it's a delete request that was converted
|
| 438 |
+
if 'delete' in user_message.lower() or 'remove' in user_message.lower():
|
| 439 |
+
response = "✅ I'll archive those emails for you (for safety, I archive instead of delete). Check the rules panel to preview the rule!"
|
| 440 |
+
else:
|
| 441 |
+
response = "✅ I've created a rule based on your request. Check the rules panel to preview how it will organize your emails!"
|
| 442 |
+
|
| 443 |
+
# Add hidden JSON marker
|
| 444 |
+
response += f"\n<!-- RULES_JSON_START\n{json.dumps([extracted_rule])}\nRULES_JSON_END -->"
|
| 445 |
+
|
| 446 |
+
return {
|
| 447 |
+
'response': response,
|
| 448 |
+
'rules': [extracted_rule],
|
| 449 |
+
'thinking_process': []
|
| 450 |
+
}
|
| 451 |
+
else:
|
| 452 |
+
logger.info("No rule extracted - falling through to general conversation")
|
| 453 |
+
|
| 454 |
+
# PRIORITY 2: Check for analysis request
|
| 455 |
+
analyze_keywords = ['analyze', 'suggest', 'patterns', 'help me organize',
|
| 456 |
+
'what rules', 'find patterns', 'inbox analysis']
|
| 457 |
+
should_analyze = any(keyword in user_message.lower() for keyword in analyze_keywords)
|
| 458 |
+
|
| 459 |
+
if should_analyze:
|
| 460 |
+
logger.info("Analysis requested - analyzing email patterns")
|
| 461 |
+
|
| 462 |
+
if callback:
|
| 463 |
+
callback({'type': 'analyzer_feedback', 'content': f'📊 Analyzing {len(emails)} emails for patterns...'})
|
| 464 |
+
|
| 465 |
+
result = analyze_emails_and_propose_rules(emails)
|
| 466 |
+
|
| 467 |
+
if result['success'] and result['rules']:
|
| 468 |
+
# Simple response - rules shown in panel
|
| 469 |
+
response = f"📊 I analyzed your {len(emails)} emails and found {len(result['rules'])} patterns! Check the rules panel to see my suggestions."
|
| 470 |
+
|
| 471 |
+
# Add hidden JSON marker
|
| 472 |
+
response += f"\n<!-- RULES_JSON_START\n{json.dumps(result['rules'])}\nRULES_JSON_END -->"
|
| 473 |
+
|
| 474 |
+
return {
|
| 475 |
+
'response': response,
|
| 476 |
+
'rules': result['rules'],
|
| 477 |
+
'thinking_process': []
|
| 478 |
+
}
|
| 479 |
+
else:
|
| 480 |
+
return {
|
| 481 |
+
'response': result['message'],
|
| 482 |
+
'rules': [],
|
| 483 |
+
'thinking_process': []
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
# PRIORITY 3: General conversation
|
| 487 |
+
# Simple greetings
|
| 488 |
+
greetings = ['hi', 'hello', 'hey', 'good morning', 'good afternoon']
|
| 489 |
+
is_greeting = any(greeting in user_message.lower().split() for greeting in greetings)
|
| 490 |
+
|
| 491 |
+
if is_greeting:
|
| 492 |
+
response = """👋 Hi there! I'm your email organization assistant. I can help you:
|
| 493 |
+
|
| 494 |
+
• **Create rules from natural language** - Just tell me what you want!
|
| 495 |
+
Example: "Archive all marketing emails"
|
| 496 |
+
|
| 497 |
+
• **Analyze your inbox** - I'll find patterns and suggest smart rules
|
| 498 |
+
Example: "Analyze my emails and suggest organization rules"
|
| 499 |
+
|
| 500 |
+
What would you like to do?"""
|
| 501 |
+
return {
|
| 502 |
+
'response': response,
|
| 503 |
+
'rules': [],
|
| 504 |
+
'thinking_process': []
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
# Other general conversation
|
| 508 |
+
llm = get_llm(use_thinking=False)
|
| 509 |
+
|
| 510 |
+
messages = [
|
| 511 |
+
SystemMessage(content="""You are a helpful email organization assistant. Be concise and friendly.
|
| 512 |
+
If the user seems to want email organization help, mention you can:
|
| 513 |
+
1. Create rules from their natural language requests (e.g., "archive all newsletters")
|
| 514 |
+
2. Analyze their inbox for patterns"""),
|
| 515 |
+
HumanMessage(content=user_message)
|
| 516 |
+
]
|
| 517 |
+
|
| 518 |
+
# Add recent conversation history for context
|
| 519 |
+
if conversation_history:
|
| 520 |
+
for msg in conversation_history[-4:]: # Last 4 messages
|
| 521 |
+
if msg['role'] == 'user':
|
| 522 |
+
messages.insert(-1, HumanMessage(content=msg['content']))
|
| 523 |
+
else:
|
| 524 |
+
messages.insert(-1, AIMessage(content=msg['content']))
|
| 525 |
+
|
| 526 |
+
response = llm.invoke(messages)
|
| 527 |
+
|
| 528 |
+
return {
|
| 529 |
+
'response': response.content,
|
| 530 |
+
'rules': [],
|
| 531 |
+
'thinking_process': []
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
except Exception as e:
|
| 535 |
+
logger.error(f"Error in process_chat_message: {e}")
|
| 536 |
+
return {
|
| 537 |
+
'response': f"I encountered an error: {str(e)}. Please try again.",
|
| 538 |
+
'rules': [],
|
| 539 |
+
'thinking_process': [f"Error: {str(e)}"]
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def extract_rules_from_output(output: str) -> List[Dict[str, Any]]:
|
| 544 |
+
"""Extract JSON rules from output string"""
|
| 545 |
+
try:
|
| 546 |
+
# Look for hidden JSON markers
|
| 547 |
+
hidden_json = re.search(r'<!-- RULES_JSON_START\s*(.*?)\s*RULES_JSON_END -->', output, re.DOTALL)
|
| 548 |
+
if hidden_json:
|
| 549 |
+
json_content = hidden_json.group(1).strip()
|
| 550 |
+
return json.loads(json_content)
|
| 551 |
+
except (json.JSONDecodeError, AttributeError) as e:
|
| 552 |
+
pass
|
| 553 |
+
return []
|
components/ui.py
ADDED
|
@@ -0,0 +1,2237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI Components for Email Rule Agent - Refactored with proper session management
|
| 3 |
+
Self-contained implementation with all necessary functions
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import List, Dict, Any, Tuple
|
| 12 |
+
|
| 13 |
+
# Import UI utility functions
|
| 14 |
+
from .ui_utils import (
|
| 15 |
+
create_email_html,
|
| 16 |
+
create_interactive_rule_cards,
|
| 17 |
+
create_preview_banner,
|
| 18 |
+
filter_emails,
|
| 19 |
+
get_folder_dropdown_choices,
|
| 20 |
+
get_preview_rules_count,
|
| 21 |
+
get_folder_counts,
|
| 22 |
+
extract_folder_name,
|
| 23 |
+
format_date,
|
| 24 |
+
sort_emails
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Import agent
|
| 28 |
+
from .simple_agent import process_chat_message as process_agent_message
|
| 29 |
+
from .ui_tools import process_chat_with_tools
|
| 30 |
+
from .ui_chat import process_chat_streaming
|
| 31 |
+
|
| 32 |
+
# Import MCP client
|
| 33 |
+
from .mcp_client import MCPClient
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def handle_preview_rule(
|
| 39 |
+
rule_id: str,
|
| 40 |
+
sort_option: str,
|
| 41 |
+
search_query: str,
|
| 42 |
+
mcp_client: Any,
|
| 43 |
+
pending_rules, # Removed type hint to avoid Gradio API generation issue
|
| 44 |
+
current_emails, # Removed type hint to avoid Gradio API generation issue
|
| 45 |
+
sample_emails, # Removed type hint to avoid Gradio API generation issue
|
| 46 |
+
preview_emails # Removed type hint to avoid Gradio API generation issue
|
| 47 |
+
) -> Tuple:
|
| 48 |
+
"""
|
| 49 |
+
Handle rule preview by delegating to MCP backend
|
| 50 |
+
|
| 51 |
+
Returns all 7 outputs expected by the UI:
|
| 52 |
+
1. folder_dropdown update
|
| 53 |
+
2. email_display HTML
|
| 54 |
+
3. rule_cards HTML
|
| 55 |
+
4. preview_banner HTML
|
| 56 |
+
5. status_msg
|
| 57 |
+
6. pending_rules state
|
| 58 |
+
7. current_emails state
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
print(f"DEBUG: handle_preview_rule called with rule_id='{rule_id}'")
|
| 62 |
+
|
| 63 |
+
# Validate input
|
| 64 |
+
if not rule_id or rule_id.strip() == "":
|
| 65 |
+
print("DEBUG: No rule ID provided")
|
| 66 |
+
return (
|
| 67 |
+
gr.update(), # No dropdown change
|
| 68 |
+
create_email_html(current_emails),
|
| 69 |
+
create_interactive_rule_cards(pending_rules),
|
| 70 |
+
create_preview_banner(0),
|
| 71 |
+
"❌ Error: No rule ID provided",
|
| 72 |
+
pending_rules,
|
| 73 |
+
current_emails
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Find the rule - use rule_id consistently
|
| 77 |
+
print(f"DEBUG: Looking for rule with id='{rule_id}' in {len(pending_rules)} rules")
|
| 78 |
+
print(f"DEBUG: Available rule IDs: {[r.get('rule_id', 'NO_ID') for r in pending_rules]}")
|
| 79 |
+
|
| 80 |
+
rule = next((r for r in pending_rules if r.get("rule_id") == rule_id), None)
|
| 81 |
+
|
| 82 |
+
if not rule:
|
| 83 |
+
print(f"DEBUG: Rule not found with rule_id='{rule_id}'")
|
| 84 |
+
return (
|
| 85 |
+
gr.update(),
|
| 86 |
+
create_email_html(current_emails),
|
| 87 |
+
create_interactive_rule_cards(pending_rules),
|
| 88 |
+
create_preview_banner(0),
|
| 89 |
+
f"❌ Rule not found: {rule_id}",
|
| 90 |
+
pending_rules,
|
| 91 |
+
current_emails
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
# Call MCP backend to get preview
|
| 96 |
+
preview_response = mcp_client.preview_rule(rule)
|
| 97 |
+
|
| 98 |
+
if preview_response.get('success', False):
|
| 99 |
+
# Create a new list of rules with updated status
|
| 100 |
+
updated_rules = []
|
| 101 |
+
for r in pending_rules:
|
| 102 |
+
if r.get("rule_id") == rule_id:
|
| 103 |
+
# Create a new rule object with preview status
|
| 104 |
+
updated_rule = r.copy()
|
| 105 |
+
updated_rule["status"] = "preview"
|
| 106 |
+
updated_rules.append(updated_rule)
|
| 107 |
+
else:
|
| 108 |
+
updated_rules.append(r)
|
| 109 |
+
|
| 110 |
+
# Get the affected emails from backend
|
| 111 |
+
affected_emails = preview_response.get('affected_emails', [])
|
| 112 |
+
|
| 113 |
+
# If we got emails, use them for display
|
| 114 |
+
if affected_emails:
|
| 115 |
+
display_emails = affected_emails
|
| 116 |
+
else:
|
| 117 |
+
# Fallback to current emails
|
| 118 |
+
display_emails = current_emails
|
| 119 |
+
|
| 120 |
+
# Get folder choices from the emails we're displaying
|
| 121 |
+
folder_choices = get_folder_dropdown_choices(display_emails)
|
| 122 |
+
if not folder_choices:
|
| 123 |
+
folder_choices = ["Inbox (0)"]
|
| 124 |
+
|
| 125 |
+
# Filter and create display
|
| 126 |
+
folder_value = folder_choices[0]
|
| 127 |
+
filtered = filter_emails(folder_value, search_query, sort_option, display_emails)
|
| 128 |
+
email_html = create_email_html(filtered, folder_value)
|
| 129 |
+
|
| 130 |
+
# Create status message
|
| 131 |
+
stats = preview_response.get('statistics', {})
|
| 132 |
+
status_msg = f"👁️ Preview: {stats.get('matched_count', 0)} emails would be affected"
|
| 133 |
+
|
| 134 |
+
return (
|
| 135 |
+
gr.update(choices=folder_choices, value=folder_value),
|
| 136 |
+
email_html,
|
| 137 |
+
create_interactive_rule_cards(updated_rules),
|
| 138 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 139 |
+
status_msg,
|
| 140 |
+
updated_rules, # Return the new list of rules
|
| 141 |
+
display_emails # Update current emails to preview data
|
| 142 |
+
)
|
| 143 |
+
else:
|
| 144 |
+
# Handle structured errors from the backend or client
|
| 145 |
+
error_message = preview_response.get('error', 'Preview failed')
|
| 146 |
+
status_code = preview_response.get('status_code')
|
| 147 |
+
|
| 148 |
+
# Show specific error message
|
| 149 |
+
status_msg = f"❌ {error_message}"
|
| 150 |
+
return (
|
| 151 |
+
gr.update(), # No dropdown change
|
| 152 |
+
create_email_html(current_emails),
|
| 153 |
+
create_interactive_rule_cards(pending_rules),
|
| 154 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 155 |
+
status_msg,
|
| 156 |
+
pending_rules,
|
| 157 |
+
current_emails
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
print(f"Error in preview handler: {e}")
|
| 162 |
+
status_msg = f"❌ Connection error: {str(e)}"
|
| 163 |
+
return (
|
| 164 |
+
gr.update(), # No dropdown change
|
| 165 |
+
create_email_html(current_emails),
|
| 166 |
+
create_interactive_rule_cards(pending_rules),
|
| 167 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 168 |
+
status_msg,
|
| 169 |
+
pending_rules,
|
| 170 |
+
current_emails
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def handle_accept_rule(rule_id, mcp_client, current_folder, sort_option, search_query,
|
| 178 |
+
pending_rules, applied_rules, current_emails, sample_emails):
|
| 179 |
+
"""Handle rule acceptance via MCP backend"""
|
| 180 |
+
|
| 181 |
+
if not rule_id or rule_id.strip() == "":
|
| 182 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 183 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 184 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 185 |
+
"❌ Error: No rule ID provided",
|
| 186 |
+
create_email_html(filtered_emails, current_folder),
|
| 187 |
+
gr.update(choices=get_folder_dropdown_choices(current_emails)),
|
| 188 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 189 |
+
|
| 190 |
+
# Find the rule
|
| 191 |
+
rule = next((r for r in pending_rules if r.get("rule_id") == rule_id), None)
|
| 192 |
+
if not rule:
|
| 193 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 194 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 195 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 196 |
+
f"❌ Rule not found: {rule_id}",
|
| 197 |
+
create_email_html(filtered_emails, current_folder),
|
| 198 |
+
gr.update(),
|
| 199 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 200 |
+
|
| 201 |
+
try:
|
| 202 |
+
# Apply rule via MCP
|
| 203 |
+
apply_response = mcp_client.apply_rule(rule, preview=False)
|
| 204 |
+
|
| 205 |
+
if apply_response.get('success', False):
|
| 206 |
+
# Rule is already saved by the apply_rule endpoint, no need to save again
|
| 207 |
+
|
| 208 |
+
# Re-fetch current state from backend (single source of truth)
|
| 209 |
+
updated_rules = mcp_client.get_rules()
|
| 210 |
+
|
| 211 |
+
# Get updated emails if provided
|
| 212 |
+
updated_emails = apply_response.get('updated_emails', current_emails)
|
| 213 |
+
|
| 214 |
+
# Update UI
|
| 215 |
+
stats = apply_response.get('statistics', {})
|
| 216 |
+
status_msg = f"✅ Rule applied: {stats.get('processed_count', 0)} emails processed"
|
| 217 |
+
|
| 218 |
+
new_folder_choices = get_folder_dropdown_choices(updated_emails)
|
| 219 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, updated_emails)
|
| 220 |
+
|
| 221 |
+
return (create_interactive_rule_cards(updated_rules),
|
| 222 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 223 |
+
status_msg,
|
| 224 |
+
create_email_html(filtered_emails, current_folder),
|
| 225 |
+
gr.update(choices=new_folder_choices),
|
| 226 |
+
updated_rules, applied_rules, updated_emails, updated_emails)
|
| 227 |
+
else:
|
| 228 |
+
error_msg = apply_response.get('error', 'Failed to apply rule')
|
| 229 |
+
status_msg = f"❌ {error_msg}"
|
| 230 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 231 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 232 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 233 |
+
status_msg,
|
| 234 |
+
create_email_html(filtered_emails, current_folder),
|
| 235 |
+
gr.update(),
|
| 236 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 237 |
+
|
| 238 |
+
except Exception as e:
|
| 239 |
+
print(f"Error calling MCP apply: {e}")
|
| 240 |
+
status_msg = f"❌ Connection error: {str(e)}"
|
| 241 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 242 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 243 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 244 |
+
status_msg,
|
| 245 |
+
create_email_html(filtered_emails, current_folder),
|
| 246 |
+
gr.update(),
|
| 247 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def handle_run_rule(rule_id, mcp_client, current_folder, sort_option, search_query,
|
| 255 |
+
pending_rules, applied_rules, current_emails, sample_emails):
|
| 256 |
+
"""Handle running an accepted rule via MCP backend"""
|
| 257 |
+
|
| 258 |
+
if not rule_id or rule_id.strip() == "":
|
| 259 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 260 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 261 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 262 |
+
"❌ Error: No rule ID provided",
|
| 263 |
+
create_email_html(filtered_emails, current_folder),
|
| 264 |
+
gr.update(choices=get_folder_dropdown_choices(current_emails)),
|
| 265 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 266 |
+
|
| 267 |
+
for rule in pending_rules:
|
| 268 |
+
if rule["id"] == rule_id and rule.get("status") == "accepted":
|
| 269 |
+
try:
|
| 270 |
+
# Apply rule via MCP client
|
| 271 |
+
apply_response = mcp_client.apply_rule(rule, preview=False)
|
| 272 |
+
|
| 273 |
+
if apply_response.get('success', False):
|
| 274 |
+
stats = apply_response.get('statistics', {})
|
| 275 |
+
status_msg = f"▶️ Rule executed: {stats.get('processed_count', 0)} emails processed"
|
| 276 |
+
|
| 277 |
+
# Update emails if provided
|
| 278 |
+
if 'updated_emails' in apply_response:
|
| 279 |
+
sample_emails = apply_response['updated_emails']
|
| 280 |
+
current_emails = sample_emails
|
| 281 |
+
|
| 282 |
+
# Update email display with new data
|
| 283 |
+
new_folder_choices = get_folder_dropdown_choices(current_emails)
|
| 284 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 285 |
+
|
| 286 |
+
print(f"Rule executed: {rule['name']} (ID: {rule_id})")
|
| 287 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 288 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 289 |
+
status_msg,
|
| 290 |
+
create_email_html(filtered_emails, current_folder),
|
| 291 |
+
gr.update(choices=new_folder_choices),
|
| 292 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 293 |
+
else:
|
| 294 |
+
error_message = apply_response.get('error', 'Failed to run rule')
|
| 295 |
+
status_msg = f"❌ {error_message}"
|
| 296 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 297 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 298 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 299 |
+
status_msg,
|
| 300 |
+
create_email_html(filtered_emails, current_folder),
|
| 301 |
+
gr.update(),
|
| 302 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 303 |
+
|
| 304 |
+
except Exception as e:
|
| 305 |
+
print(f"Error running rule: {e}")
|
| 306 |
+
status_msg = "⚠️ Connection issue - could not run rule"
|
| 307 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 308 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 309 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 310 |
+
status_msg,
|
| 311 |
+
create_email_html(filtered_emails, current_folder),
|
| 312 |
+
gr.update(),
|
| 313 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 314 |
+
|
| 315 |
+
filtered_emails = filter_emails(current_folder, search_query, sort_option, current_emails)
|
| 316 |
+
return (create_interactive_rule_cards(pending_rules),
|
| 317 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 318 |
+
f"❌ Rule not found or not accepted: {rule_id}",
|
| 319 |
+
create_email_html(filtered_emails, current_folder),
|
| 320 |
+
gr.update(),
|
| 321 |
+
pending_rules, applied_rules, current_emails, sample_emails)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def handle_archive_rule(rule_id, mcp_client, pending_rules):
|
| 329 |
+
"""Handle archiving a rule via MCP"""
|
| 330 |
+
if not rule_id or rule_id.strip() == "":
|
| 331 |
+
return (
|
| 332 |
+
create_interactive_rule_cards(pending_rules),
|
| 333 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 334 |
+
"❌ Error: No rule ID provided",
|
| 335 |
+
pending_rules
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
try:
|
| 339 |
+
# Archive via MCP
|
| 340 |
+
response = mcp_client.archive_rule(rule_id)
|
| 341 |
+
|
| 342 |
+
if response.get('success', False):
|
| 343 |
+
# Re-fetch rules from backend
|
| 344 |
+
updated_rules = mcp_client.get_rules()
|
| 345 |
+
|
| 346 |
+
rule_name = response.get('rule', {}).get('name', 'Rule')
|
| 347 |
+
status_msg = f"🗄️ Rule archived: {rule_name}"
|
| 348 |
+
|
| 349 |
+
return (
|
| 350 |
+
create_interactive_rule_cards(updated_rules),
|
| 351 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 352 |
+
status_msg,
|
| 353 |
+
updated_rules
|
| 354 |
+
)
|
| 355 |
+
else:
|
| 356 |
+
error_msg = response.get('error', 'Failed to archive')
|
| 357 |
+
return (
|
| 358 |
+
create_interactive_rule_cards(pending_rules),
|
| 359 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 360 |
+
f"❌ {error_msg}",
|
| 361 |
+
pending_rules
|
| 362 |
+
)
|
| 363 |
+
except Exception as e:
|
| 364 |
+
print(f"Error archiving rule: {e}")
|
| 365 |
+
return (
|
| 366 |
+
create_interactive_rule_cards(pending_rules),
|
| 367 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 368 |
+
f"❌ Connection error: {str(e)}",
|
| 369 |
+
pending_rules
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def handle_reactivate_rule(rule_id, mcp_client, pending_rules):
|
| 374 |
+
"""Handle reactivating an archived rule via MCP"""
|
| 375 |
+
if not rule_id or rule_id.strip() == "":
|
| 376 |
+
return (
|
| 377 |
+
create_interactive_rule_cards(pending_rules),
|
| 378 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 379 |
+
"❌ Error: No rule ID provided",
|
| 380 |
+
pending_rules
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
try:
|
| 384 |
+
# Reactivate via MCP
|
| 385 |
+
response = mcp_client.reactivate_rule(rule_id)
|
| 386 |
+
|
| 387 |
+
if response.get('success', False):
|
| 388 |
+
# Re-fetch rules from backend
|
| 389 |
+
updated_rules = mcp_client.get_rules()
|
| 390 |
+
|
| 391 |
+
rule_name = response.get('rule', {}).get('name', 'Rule')
|
| 392 |
+
status_msg = f"✅ Rule reactivated: {rule_name}"
|
| 393 |
+
|
| 394 |
+
return (
|
| 395 |
+
create_interactive_rule_cards(updated_rules),
|
| 396 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 397 |
+
status_msg,
|
| 398 |
+
updated_rules
|
| 399 |
+
)
|
| 400 |
+
else:
|
| 401 |
+
error_msg = response.get('error', 'Failed to reactivate')
|
| 402 |
+
return (
|
| 403 |
+
create_interactive_rule_cards(pending_rules),
|
| 404 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 405 |
+
f"❌ {error_msg}",
|
| 406 |
+
pending_rules
|
| 407 |
+
)
|
| 408 |
+
except Exception as e:
|
| 409 |
+
print(f"Error reactivating rule: {e}")
|
| 410 |
+
return (
|
| 411 |
+
create_interactive_rule_cards(pending_rules),
|
| 412 |
+
create_preview_banner(get_preview_rules_count(pending_rules)),
|
| 413 |
+
f"❌ Connection error: {str(e)}",
|
| 414 |
+
pending_rules
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def exit_preview_mode(sort_option, search_query, pending_rules, current_emails, sample_emails):
|
| 419 |
+
"""Exit preview mode by setting all preview rules back to pending"""
|
| 420 |
+
|
| 421 |
+
# Create new list of rules with updated status
|
| 422 |
+
updated_rules = []
|
| 423 |
+
for rule in pending_rules:
|
| 424 |
+
if rule["status"] == "preview":
|
| 425 |
+
# Create new rule object
|
| 426 |
+
updated_rule = rule.copy()
|
| 427 |
+
updated_rule["status"] = "pending"
|
| 428 |
+
updated_rules.append(updated_rule)
|
| 429 |
+
else:
|
| 430 |
+
updated_rules.append(rule)
|
| 431 |
+
|
| 432 |
+
current_emails = sample_emails
|
| 433 |
+
|
| 434 |
+
new_folder_choices = get_folder_dropdown_choices(current_emails)
|
| 435 |
+
new_folder_value = new_folder_choices[0] if new_folder_choices else "Inbox (0)"
|
| 436 |
+
filtered_emails = filter_emails(new_folder_value, search_query, sort_option, current_emails)
|
| 437 |
+
new_email_display = create_email_html(filtered_emails, new_folder_value)
|
| 438 |
+
|
| 439 |
+
return (gr.update(choices=new_folder_choices, value=new_folder_value),
|
| 440 |
+
new_email_display,
|
| 441 |
+
create_interactive_rule_cards(updated_rules),
|
| 442 |
+
create_preview_banner(get_preview_rules_count(updated_rules)),
|
| 443 |
+
"🔄 Exited preview mode",
|
| 444 |
+
updated_rules, current_emails)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def get_preview_rules_count(pending_rules):
|
| 448 |
+
"""Get the number of rules currently in preview status"""
|
| 449 |
+
return len([rule for rule in pending_rules if rule["status"] == "preview"])
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def create_app(modal_url: str) -> gr.Blocks:
|
| 453 |
+
"""
|
| 454 |
+
Create the main Gradio app
|
| 455 |
+
|
| 456 |
+
Args:
|
| 457 |
+
modal_url: URL of the Modal backend server
|
| 458 |
+
|
| 459 |
+
Returns:
|
| 460 |
+
Gradio Blocks app
|
| 461 |
+
"""
|
| 462 |
+
|
| 463 |
+
# Custom CSS for compact design with unified design system
|
| 464 |
+
custom_css = """
|
| 465 |
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
| 466 |
+
@import url('https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css');
|
| 467 |
+
|
| 468 |
+
/* Chat input alignment fix */
|
| 469 |
+
.chat-input-row {
|
| 470 |
+
display: flex;
|
| 471 |
+
align-items: stretch;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.chat-input-row > div {
|
| 475 |
+
display: flex !important;
|
| 476 |
+
align-items: stretch !important;
|
| 477 |
+
gap: 8px;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.chat-input-row textarea {
|
| 481 |
+
min-height: 39.6px;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.chat-input-row button {
|
| 485 |
+
height: auto !important;
|
| 486 |
+
padding: 0.5rem 1rem !important;
|
| 487 |
+
display: flex;
|
| 488 |
+
align-items: center;
|
| 489 |
+
justify-content: center;
|
| 490 |
+
font-size: 1.2rem;
|
| 491 |
+
min-height: 39.6px;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
/* Use high-specificity selector to ensure .hide class works */
|
| 495 |
+
gradio-app .hide {
|
| 496 |
+
display: none !important;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
/* Extra specificity for the main elements to prevent any override */
|
| 500 |
+
#main_interface.hide,
|
| 501 |
+
#todo_list_html.hide {
|
| 502 |
+
display: none !important;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
/* CSS Variables for Design System */
|
| 506 |
+
:root {
|
| 507 |
+
/* Colors */
|
| 508 |
+
--color-primary: #007bff;
|
| 509 |
+
--color-success: #28a745;
|
| 510 |
+
--color-danger: #dc3545;
|
| 511 |
+
--color-warning: #fd7e14;
|
| 512 |
+
--color-info: #17a2b8;
|
| 513 |
+
--color-dark: #212529;
|
| 514 |
+
--color-gray: #6c757d;
|
| 515 |
+
--color-gray-light: #e1e5e9;
|
| 516 |
+
--color-gray-lighter: #f8f9fa;
|
| 517 |
+
--color-white: #ffffff;
|
| 518 |
+
|
| 519 |
+
/* Typography */
|
| 520 |
+
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 521 |
+
--font-size-xs: 0.75rem; /* 12px */
|
| 522 |
+
--font-size-sm: 0.875rem; /* 14px */
|
| 523 |
+
--font-size-base: 1rem; /* 16px */
|
| 524 |
+
--font-size-lg: 1.125rem; /* 18px */
|
| 525 |
+
--font-weight-normal: 400;
|
| 526 |
+
--font-weight-medium: 500;
|
| 527 |
+
--font-weight-semibold: 600;
|
| 528 |
+
--font-weight-bold: 700;
|
| 529 |
+
|
| 530 |
+
/* Spacing (4px base unit) */
|
| 531 |
+
--spacing-1: 0.25rem; /* 4px */
|
| 532 |
+
--spacing-2: 0.5rem; /* 8px */
|
| 533 |
+
--spacing-3: 0.75rem; /* 12px */
|
| 534 |
+
--spacing-4: 1rem; /* 16px */
|
| 535 |
+
--spacing-5: 1.25rem; /* 20px */
|
| 536 |
+
--spacing-6: 1.5rem; /* 24px */
|
| 537 |
+
|
| 538 |
+
/* Borders */
|
| 539 |
+
--border-width: 1px;
|
| 540 |
+
--border-radius-sm: 4px;
|
| 541 |
+
--border-radius: 6px;
|
| 542 |
+
--border-radius-lg: 8px;
|
| 543 |
+
|
| 544 |
+
/* Shadows */
|
| 545 |
+
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
| 546 |
+
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
| 547 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
| 548 |
+
|
| 549 |
+
/* Transitions */
|
| 550 |
+
--transition-fast: 150ms ease-in-out;
|
| 551 |
+
--transition-normal: 200ms ease-in-out;
|
| 552 |
+
--transition-slow: 300ms ease-in-out;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
/* Global Styles */
|
| 556 |
+
* {
|
| 557 |
+
font-family: var(--font-family) !important;
|
| 558 |
+
box-sizing: border-box;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
/* Layout - Modern Flexbox Approach */
|
| 562 |
+
.main-container {
|
| 563 |
+
height: calc(100vh - 100px);
|
| 564 |
+
display: flex;
|
| 565 |
+
flex-direction: column;
|
| 566 |
+
overflow: hidden;
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
/* Main interface row should be flex container */
|
| 570 |
+
.main-interface-row {
|
| 571 |
+
display: flex;
|
| 572 |
+
height: 100%;
|
| 573 |
+
gap: var(--spacing-4);
|
| 574 |
+
overflow: hidden;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
/* Each column should be flex container */
|
| 578 |
+
.main-interface-row > div[class*="gr-column"] {
|
| 579 |
+
display: flex;
|
| 580 |
+
flex-direction: column;
|
| 581 |
+
height: 100%;
|
| 582 |
+
overflow: hidden;
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
/* Typography */
|
| 586 |
+
h3 {
|
| 587 |
+
margin: 0;
|
| 588 |
+
font-size: var(--font-size-base);
|
| 589 |
+
font-weight: var(--font-weight-semibold);
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
/* Components */
|
| 593 |
+
.compact-header {
|
| 594 |
+
margin: var(--spacing-2) 0;
|
| 595 |
+
display: flex;
|
| 596 |
+
align-items: center;
|
| 597 |
+
gap: var(--spacing-2);
|
| 598 |
+
min-height: 32px; /* Ensure consistent height */
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
/* Fix header alignment across columns */
|
| 602 |
+
.column-header-title {
|
| 603 |
+
display: flex;
|
| 604 |
+
align-items: center;
|
| 605 |
+
height: 32px; /* Fixed height for all headers */
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
.compact-chat {
|
| 609 |
+
flex-grow: 1;
|
| 610 |
+
min-height: 300px;
|
| 611 |
+
overflow-y: auto;
|
| 612 |
+
border: var(--border-width) solid var(--color-gray-light);
|
| 613 |
+
border-radius: var(--border-radius);
|
| 614 |
+
background: var(--color-white);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.compact-input {
|
| 618 |
+
font-size: var(--font-size-sm);
|
| 619 |
+
padding: var(--spacing-2) var(--spacing-3);
|
| 620 |
+
border: var(--border-width) solid var(--color-gray-light);
|
| 621 |
+
border-radius: var(--border-radius-sm);
|
| 622 |
+
transition: all var(--transition-fast);
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
.compact-input:focus {
|
| 626 |
+
border-color: var(--color-primary);
|
| 627 |
+
outline: none;
|
| 628 |
+
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
.compact-buttons {
|
| 632 |
+
gap: var(--spacing-2);
|
| 633 |
+
margin-top: var(--spacing-2);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
.compact-buttons button,
|
| 637 |
+
button[size="sm"] {
|
| 638 |
+
padding: var(--spacing-2) var(--spacing-3);
|
| 639 |
+
font-size: var(--font-size-sm);
|
| 640 |
+
font-weight: var(--font-weight-medium);
|
| 641 |
+
border-radius: var(--border-radius-sm);
|
| 642 |
+
transition: all var(--transition-fast);
|
| 643 |
+
cursor: pointer;
|
| 644 |
+
position: relative;
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.compact-buttons button:hover:not(:disabled) {
|
| 648 |
+
transform: translateY(-1px);
|
| 649 |
+
box-shadow: var(--shadow);
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.compact-buttons button:active:not(:disabled) {
|
| 653 |
+
transform: translateY(0);
|
| 654 |
+
box-shadow: var(--shadow-sm);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
.compact-rules {
|
| 659 |
+
flex-grow: 1;
|
| 660 |
+
overflow-y: auto;
|
| 661 |
+
padding: var(--spacing-2);
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
.compact-rules::-webkit-scrollbar,
|
| 665 |
+
.compact-emails::-webkit-scrollbar,
|
| 666 |
+
.compact-chat::-webkit-scrollbar {
|
| 667 |
+
width: 6px;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.compact-rules::-webkit-scrollbar-thumb,
|
| 671 |
+
.compact-emails::-webkit-scrollbar-thumb,
|
| 672 |
+
.compact-chat::-webkit-scrollbar-thumb {
|
| 673 |
+
background: var(--color-gray-light);
|
| 674 |
+
border-radius: 3px;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
.compact-banner {
|
| 678 |
+
margin-bottom: var(--spacing-2);
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.compact-dropdown,
|
| 682 |
+
.compact-search,
|
| 683 |
+
.compact-sort {
|
| 684 |
+
font-size: var(--font-size-sm);
|
| 685 |
+
padding: var(--spacing-1) var(--spacing-2);
|
| 686 |
+
border: var(--border-width) solid var(--color-gray-light);
|
| 687 |
+
border-radius: var(--border-radius-sm);
|
| 688 |
+
transition: border-color var(--transition-fast);
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
.compact-controls {
|
| 692 |
+
gap: var(--spacing-2);
|
| 693 |
+
margin: var(--spacing-2) 0;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.compact-emails {
|
| 697 |
+
flex-grow: 1;
|
| 698 |
+
overflow-y: auto;
|
| 699 |
+
background: var(--color-white);
|
| 700 |
+
border: var(--border-width) solid var(--color-gray-light);
|
| 701 |
+
border-radius: var(--border-radius);
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
/* Gradio Overrides */
|
| 705 |
+
.gr-form {
|
| 706 |
+
gap: var(--spacing-2) !important;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
.gr-box {
|
| 710 |
+
padding: var(--spacing-3) !important;
|
| 711 |
+
border-radius: var(--border-radius) !important;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
.gr-padded {
|
| 715 |
+
padding: var(--spacing-2) !important;
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
.gr-panel {
|
| 719 |
+
padding: var(--spacing-2) !important;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
.gr-button {
|
| 723 |
+
font-family: var(--font-family) !important;
|
| 724 |
+
font-weight: var(--font-weight-medium) !important;
|
| 725 |
+
transition: all var(--transition-fast) !important;
|
| 726 |
+
position: relative !important;
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
.gr-button:hover:not(:disabled) {
|
| 730 |
+
transform: translateY(-1px);
|
| 731 |
+
box-shadow: var(--shadow);
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
.gr-button:active:not(:disabled) {
|
| 735 |
+
transform: translateY(0);
|
| 736 |
+
box-shadow: var(--shadow-sm);
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
.gr-button[variant="primary"] {
|
| 740 |
+
background: var(--color-primary) !important;
|
| 741 |
+
border-color: var(--color-primary) !important;
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
.gr-button[variant="primary"]:hover:not(:disabled) {
|
| 745 |
+
background: #0056b3 !important;
|
| 746 |
+
border-color: #0056b3 !important;
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
.gr-button[variant="secondary"] {
|
| 750 |
+
background: var(--color-white) !important;
|
| 751 |
+
border: var(--border-width) solid var(--color-gray-light) !important;
|
| 752 |
+
color: var(--color-dark) !important;
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
.gr-button[variant="secondary"]:hover:not(:disabled) {
|
| 756 |
+
background: var(--color-gray-lighter) !important;
|
| 757 |
+
border-color: var(--color-gray) !important;
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
/* Loading state for buttons */
|
| 761 |
+
.gr-button:disabled {
|
| 762 |
+
opacity: 0.6;
|
| 763 |
+
cursor: not-allowed;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
/* All interactive elements should have transitions */
|
| 767 |
+
button, .gr-button, .email-row, .rule-card, input, select, textarea {
|
| 768 |
+
transition: all var(--transition-fast);
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
/* Markdown styling */
|
| 772 |
+
.gr-markdown {
|
| 773 |
+
font-size: var(--font-size-sm);
|
| 774 |
+
line-height: 1.5;
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
.gr-markdown h3 {
|
| 778 |
+
font-size: var(--font-size-base);
|
| 779 |
+
font-weight: var(--font-weight-semibold);
|
| 780 |
+
margin: var(--spacing-2) 0;
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
/* Chatbot styling */
|
| 784 |
+
.gr-chatbot {
|
| 785 |
+
font-size: var(--font-size-sm);
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
.gr-chatbot .message {
|
| 789 |
+
padding: var(--spacing-3);
|
| 790 |
+
margin: var(--spacing-2) 0;
|
| 791 |
+
border-radius: var(--border-radius);
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
/* Send button specific */
|
| 795 |
+
button[min_width="45"] {
|
| 796 |
+
min-width: 45px !important;
|
| 797 |
+
width: 45px !important;
|
| 798 |
+
padding: var(--spacing-2) !important;
|
| 799 |
+
display: flex;
|
| 800 |
+
align-items: center;
|
| 801 |
+
justify-content: center;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
/* Draft-specific styles */
|
| 805 |
+
.draft-indicator {
|
| 806 |
+
font-size: var(--font-size-sm);
|
| 807 |
+
margin-right: var(--spacing-1);
|
| 808 |
+
display: inline-flex;
|
| 809 |
+
align-items: center;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
.draft-preview {
|
| 813 |
+
margin-top: var(--spacing-4);
|
| 814 |
+
padding: var(--spacing-3);
|
| 815 |
+
background-color: #e3f2fd;
|
| 816 |
+
border-radius: var(--border-radius);
|
| 817 |
+
border-left: 4px solid #2196f3;
|
| 818 |
+
animation: fadeIn var(--transition-normal);
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
.draft-preview h4 {
|
| 822 |
+
margin: 0 0 var(--spacing-2) 0;
|
| 823 |
+
color: #1976d2;
|
| 824 |
+
font-size: var(--font-size-sm);
|
| 825 |
+
display: flex;
|
| 826 |
+
align-items: center;
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
.draft-preview button {
|
| 830 |
+
font-size: var(--font-size-xs);
|
| 831 |
+
padding: 4px 12px;
|
| 832 |
+
background: #2196f3;
|
| 833 |
+
color: white;
|
| 834 |
+
border: none;
|
| 835 |
+
border-radius: var(--border-radius-sm);
|
| 836 |
+
cursor: pointer;
|
| 837 |
+
transition: all var(--transition-fast);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
.draft-preview button:hover:not(:disabled) {
|
| 841 |
+
background: #1976d2;
|
| 842 |
+
transform: translateY(-1px);
|
| 843 |
+
box-shadow: var(--shadow-sm);
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
.draft-preview button:active:not(:disabled) {
|
| 847 |
+
transform: translateY(0);
|
| 848 |
+
box-shadow: none;
|
| 849 |
+
}
|
| 850 |
+
|
| 851 |
+
@keyframes fadeIn {
|
| 852 |
+
from { opacity: 0; transform: translateY(-10px); }
|
| 853 |
+
to { opacity: 1; transform: translateY(0); }
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
/* New sleek two-row rule cards */
|
| 857 |
+
.rule-card-v2 {
|
| 858 |
+
border: 1px solid var(--color-gray-light);
|
| 859 |
+
border-left-width: 4px;
|
| 860 |
+
padding: var(--spacing-2) var(--spacing-3);
|
| 861 |
+
margin-bottom: var(--spacing-2);
|
| 862 |
+
background: var(--color-white);
|
| 863 |
+
transition: all var(--transition-fast);
|
| 864 |
+
border-radius: var(--border-radius-sm);
|
| 865 |
+
cursor: pointer;
|
| 866 |
+
}
|
| 867 |
+
|
| 868 |
+
.rule-card-v2:hover {
|
| 869 |
+
box-shadow: var(--shadow);
|
| 870 |
+
transform: translateY(-1px);
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
/* Expand arrow */
|
| 874 |
+
.rule-expand-arrow {
|
| 875 |
+
display: inline-block;
|
| 876 |
+
width: 16px;
|
| 877 |
+
color: var(--color-gray);
|
| 878 |
+
font-size: var(--font-size-xs);
|
| 879 |
+
transition: transform var(--transition-fast);
|
| 880 |
+
margin-right: var(--spacing-2);
|
| 881 |
+
}
|
| 882 |
+
|
| 883 |
+
.rule-expand-arrow.expanded {
|
| 884 |
+
transform: rotate(90deg);
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
/* Rule details section */
|
| 888 |
+
.rule-details {
|
| 889 |
+
padding-top: var(--spacing-2);
|
| 890 |
+
border-top: 1px solid var(--color-gray-light);
|
| 891 |
+
margin-top: var(--spacing-2);
|
| 892 |
+
font-size: var(--font-size-xs);
|
| 893 |
+
color: var(--color-dark);
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
.rule-details strong {
|
| 897 |
+
color: var(--color-gray);
|
| 898 |
+
font-weight: var(--font-weight-semibold);
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
.rule-details ul {
|
| 902 |
+
list-style-type: disc;
|
| 903 |
+
margin: var(--spacing-1) 0;
|
| 904 |
+
}
|
| 905 |
+
|
| 906 |
+
.rule-details li {
|
| 907 |
+
color: var(--color-dark);
|
| 908 |
+
line-height: 1.4;
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
/* Status-specific left border colors */
|
| 912 |
+
.rule-status-pending { border-left-color: var(--color-gray); }
|
| 913 |
+
.rule-status-preview { border-left-color: var(--color-warning); }
|
| 914 |
+
.rule-status-accepted { border-left-color: var(--color-success); }
|
| 915 |
+
|
| 916 |
+
.rule-row-1, .rule-row-2 {
|
| 917 |
+
display: flex;
|
| 918 |
+
align-items: center;
|
| 919 |
+
justify-content: space-between;
|
| 920 |
+
}
|
| 921 |
+
|
| 922 |
+
.rule-row-1 {
|
| 923 |
+
margin-bottom: var(--spacing-2);
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
.rule-name {
|
| 927 |
+
font-weight: var(--font-weight-semibold);
|
| 928 |
+
font-size: var(--font-size-sm);
|
| 929 |
+
color: var(--color-dark);
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
.rule-actions {
|
| 933 |
+
display: flex;
|
| 934 |
+
gap: var(--spacing-2);
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
/* Button specific styles */
|
| 938 |
+
.rule-btn {
|
| 939 |
+
padding: 6px 12px;
|
| 940 |
+
font-size: var(--font-size-xs);
|
| 941 |
+
font-weight: var(--font-weight-medium);
|
| 942 |
+
border-radius: var(--border-radius-sm);
|
| 943 |
+
transition: all var(--transition-fast);
|
| 944 |
+
cursor: pointer;
|
| 945 |
+
border: 1px solid transparent;
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
.rule-btn:hover {
|
| 949 |
+
transform: translateY(-1px);
|
| 950 |
+
box-shadow: var(--shadow-sm);
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
.rule-btn.accept-btn {
|
| 954 |
+
background-color: var(--color-success);
|
| 955 |
+
color: white;
|
| 956 |
+
border-color: var(--color-success);
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
.rule-btn.accept-btn:hover {
|
| 960 |
+
background-color: #218838;
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
.rule-btn.reject-btn {
|
| 964 |
+
background-color: var(--color-danger);
|
| 965 |
+
color: white;
|
| 966 |
+
border-color: var(--color-danger);
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
.rule-btn.reject-btn:hover {
|
| 970 |
+
background-color: #c82333;
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
.rule-btn.run-btn, .rule-btn.preview-btn {
|
| 974 |
+
background-color: var(--color-white);
|
| 975 |
+
color: var(--color-primary);
|
| 976 |
+
border: 1px solid var(--color-primary);
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
.rule-btn.run-btn:hover, .rule-btn.preview-btn:hover {
|
| 980 |
+
background-color: var(--color-primary);
|
| 981 |
+
color: white;
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
.rule-btn.archive-btn {
|
| 985 |
+
background-color: transparent;
|
| 986 |
+
color: var(--color-gray);
|
| 987 |
+
border: 1px solid var(--color-gray-light);
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
.rule-btn.archive-btn:hover {
|
| 991 |
+
background-color: var(--color-gray-lighter);
|
| 992 |
+
border-color: var(--color-gray);
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
/* Demo Checklist Styles */
|
| 996 |
+
.demo-checklist {
|
| 997 |
+
position: fixed;
|
| 998 |
+
top: 80px;
|
| 999 |
+
right: 20px;
|
| 1000 |
+
width: 320px;
|
| 1001 |
+
background: var(--color-white);
|
| 1002 |
+
border: 2px solid var(--color-primary);
|
| 1003 |
+
border-radius: var(--border-radius-lg);
|
| 1004 |
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
| 1005 |
+
z-index: 1000;
|
| 1006 |
+
display: none;
|
| 1007 |
+
flex-direction: column;
|
| 1008 |
+
transition: all var(--transition-normal);
|
| 1009 |
+
}
|
| 1010 |
+
|
| 1011 |
+
.demo-checklist.show {
|
| 1012 |
+
display: flex;
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
.demo-checklist.initial-show {
|
| 1016 |
+
animation: slideInRight 0.5s ease-out forwards;
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
.demo-checklist.minimized {
|
| 1020 |
+
height: auto;
|
| 1021 |
+
}
|
| 1022 |
+
|
| 1023 |
+
.demo-checklist.minimized .checklist-body {
|
| 1024 |
+
display: none;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
@keyframes slideInRight {
|
| 1028 |
+
from {
|
| 1029 |
+
transform: translateX(400px);
|
| 1030 |
+
opacity: 0;
|
| 1031 |
+
}
|
| 1032 |
+
to {
|
| 1033 |
+
transform: translateX(0);
|
| 1034 |
+
opacity: 1;
|
| 1035 |
+
}
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
@keyframes pulse {
|
| 1039 |
+
0%, 100% { transform: scale(1); }
|
| 1040 |
+
50% { transform: scale(1.05); }
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
.demo-checklist.pulse-animation {
|
| 1044 |
+
animation: pulse 0.5s ease-out 3;
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
.checklist-header {
|
| 1048 |
+
padding: var(--spacing-3) var(--spacing-4);
|
| 1049 |
+
background: linear-gradient(135deg, var(--color-primary), #0056b3);
|
| 1050 |
+
color: var(--color-white);
|
| 1051 |
+
cursor: move;
|
| 1052 |
+
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
| 1053 |
+
display: flex;
|
| 1054 |
+
justify-content: space-between;
|
| 1055 |
+
align-items: center;
|
| 1056 |
+
font-weight: var(--font-weight-bold);
|
| 1057 |
+
font-size: var(--font-size-base);
|
| 1058 |
+
user-select: none;
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
.checklist-header:hover {
|
| 1062 |
+
background: linear-gradient(135deg, #0056b3, var(--color-primary));
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
.checklist-header:active {
|
| 1066 |
+
cursor: grabbing;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
.minimize-btn {
|
| 1070 |
+
background: rgba(255, 255, 255, 0.2);
|
| 1071 |
+
border: none;
|
| 1072 |
+
color: var(--color-white);
|
| 1073 |
+
padding: 4px 8px;
|
| 1074 |
+
border-radius: var(--border-radius-sm);
|
| 1075 |
+
cursor: pointer;
|
| 1076 |
+
font-size: var(--font-size-sm);
|
| 1077 |
+
transition: all var(--transition-fast);
|
| 1078 |
+
}
|
| 1079 |
+
|
| 1080 |
+
.minimize-btn:hover {
|
| 1081 |
+
background: rgba(255, 255, 255, 0.3);
|
| 1082 |
+
transform: scale(1.1);
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
.checklist-body {
|
| 1086 |
+
padding: var(--spacing-3);
|
| 1087 |
+
overflow-y: auto;
|
| 1088 |
+
flex: 1;
|
| 1089 |
+
max-height: 350px;
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
.progress-section {
|
| 1093 |
+
margin-bottom: var(--spacing-3);
|
| 1094 |
+
padding: var(--spacing-2);
|
| 1095 |
+
background: var(--color-gray-lighter);
|
| 1096 |
+
border-radius: var(--border-radius-sm);
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
.progress-label {
|
| 1100 |
+
font-size: var(--font-size-sm);
|
| 1101 |
+
font-weight: var(--font-weight-medium);
|
| 1102 |
+
color: var(--color-dark);
|
| 1103 |
+
margin-bottom: var(--spacing-1);
|
| 1104 |
+
}
|
| 1105 |
+
|
| 1106 |
+
.progress-bar {
|
| 1107 |
+
width: 100%;
|
| 1108 |
+
height: 8px;
|
| 1109 |
+
background: var(--color-gray-light);
|
| 1110 |
+
border-radius: 4px;
|
| 1111 |
+
overflow: hidden;
|
| 1112 |
+
}
|
| 1113 |
+
|
| 1114 |
+
.progress-fill {
|
| 1115 |
+
height: 100%;
|
| 1116 |
+
background: linear-gradient(90deg, var(--color-success), #22c55e);
|
| 1117 |
+
width: 0%;
|
| 1118 |
+
transition: width var(--transition-normal);
|
| 1119 |
+
}
|
| 1120 |
+
|
| 1121 |
+
.checklist-item {
|
| 1122 |
+
padding: var(--spacing-2) var(--spacing-1);
|
| 1123 |
+
border-radius: var(--border-radius-sm);
|
| 1124 |
+
display: flex;
|
| 1125 |
+
align-items: center;
|
| 1126 |
+
gap: var(--spacing-2);
|
| 1127 |
+
transition: all var(--transition-fast);
|
| 1128 |
+
cursor: pointer;
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
.checklist-item:hover {
|
| 1132 |
+
background: var(--color-gray-lighter);
|
| 1133 |
+
transform: translateX(4px);
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
.checklist-checkbox {
|
| 1137 |
+
width: 18px;
|
| 1138 |
+
height: 18px;
|
| 1139 |
+
cursor: pointer;
|
| 1140 |
+
accent-color: var(--color-success);
|
| 1141 |
+
}
|
| 1142 |
+
|
| 1143 |
+
.checklist-text {
|
| 1144 |
+
flex: 1;
|
| 1145 |
+
font-size: var(--font-size-sm);
|
| 1146 |
+
transition: all var(--transition-fast);
|
| 1147 |
+
user-select: none;
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
.checklist-item.completed .checklist-text {
|
| 1151 |
+
text-decoration: line-through;
|
| 1152 |
+
color: var(--color-gray);
|
| 1153 |
+
opacity: 0.7;
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
.checklist-item.completed {
|
| 1157 |
+
background: rgba(40, 167, 69, 0.1);
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
/* Animation for completion */
|
| 1161 |
+
@keyframes checkComplete {
|
| 1162 |
+
0% { transform: scale(1) rotate(0deg); }
|
| 1163 |
+
50% { transform: scale(1.2) rotate(5deg); }
|
| 1164 |
+
100% { transform: scale(1) rotate(0deg); }
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
.checklist-item.completed .checklist-checkbox {
|
| 1168 |
+
animation: checkComplete 0.3s ease-out;
|
| 1169 |
+
}
|
| 1170 |
+
|
| 1171 |
+
/* Header progress indicator */
|
| 1172 |
+
.header-progress {
|
| 1173 |
+
display: none;
|
| 1174 |
+
font-size: 0.9rem;
|
| 1175 |
+
color: rgba(255, 255, 255, 0.9);
|
| 1176 |
+
margin: 0 10px;
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
.demo-checklist.minimized .header-progress {
|
| 1180 |
+
display: inline;
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
/* Celebration Overlay Styles */
|
| 1184 |
+
.celebration-overlay {
|
| 1185 |
+
position: absolute;
|
| 1186 |
+
top: 0;
|
| 1187 |
+
left: 0;
|
| 1188 |
+
width: 100%;
|
| 1189 |
+
height: 100%;
|
| 1190 |
+
background-color: rgba(0, 0, 0, 0.5);
|
| 1191 |
+
display: flex;
|
| 1192 |
+
justify-content: center;
|
| 1193 |
+
align-items: center;
|
| 1194 |
+
opacity: 0;
|
| 1195 |
+
pointer-events: none;
|
| 1196 |
+
transition: opacity 0.3s ease-in-out;
|
| 1197 |
+
border-radius: var(--border-radius-lg);
|
| 1198 |
+
z-index: 10;
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
.celebration-overlay:not(.hidden) {
|
| 1202 |
+
opacity: 1;
|
| 1203 |
+
pointer-events: auto;
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
.celebration-content {
|
| 1207 |
+
background: white;
|
| 1208 |
+
padding: 2rem;
|
| 1209 |
+
border-radius: 12px;
|
| 1210 |
+
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
| 1211 |
+
text-align: center;
|
| 1212 |
+
transform: scale(0.8);
|
| 1213 |
+
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
| 1214 |
+
position: relative;
|
| 1215 |
+
}
|
| 1216 |
+
|
| 1217 |
+
.celebration-overlay:not(.hidden) .celebration-content {
|
| 1218 |
+
transform: scale(1);
|
| 1219 |
+
}
|
| 1220 |
+
|
| 1221 |
+
.celebration-close {
|
| 1222 |
+
position: absolute;
|
| 1223 |
+
top: 10px;
|
| 1224 |
+
right: 10px;
|
| 1225 |
+
background: none;
|
| 1226 |
+
border: none;
|
| 1227 |
+
font-size: 24px;
|
| 1228 |
+
cursor: pointer;
|
| 1229 |
+
color: #666;
|
| 1230 |
+
transition: color 0.2s;
|
| 1231 |
+
}
|
| 1232 |
+
|
| 1233 |
+
.celebration-close:hover {
|
| 1234 |
+
color: #333;
|
| 1235 |
+
}
|
| 1236 |
+
|
| 1237 |
+
.celebration-heading {
|
| 1238 |
+
margin: 0 0 1rem 0;
|
| 1239 |
+
font-size: 1.5rem;
|
| 1240 |
+
color: var(--color-primary);
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
.celebration-message {
|
| 1244 |
+
margin: 0;
|
| 1245 |
+
font-size: 1rem;
|
| 1246 |
+
color: #555;
|
| 1247 |
+
}
|
| 1248 |
+
"""
|
| 1249 |
+
|
| 1250 |
+
# Create the Gradio interface
|
| 1251 |
+
with gr.Blocks(title="Email Rule Agent", theme=gr.themes.Soft(), css=custom_css) as demo:
|
| 1252 |
+
# Initialize state variables
|
| 1253 |
+
session_id = gr.State(value=str(uuid.uuid4()))
|
| 1254 |
+
mcp_client_state = gr.State()
|
| 1255 |
+
|
| 1256 |
+
# Email state
|
| 1257 |
+
sample_emails_state = gr.State([])
|
| 1258 |
+
preview_emails_state = gr.State([])
|
| 1259 |
+
current_emails_state = gr.State([])
|
| 1260 |
+
expanded_email_id_state = gr.State(None)
|
| 1261 |
+
|
| 1262 |
+
# Rule state
|
| 1263 |
+
pending_rules_state = gr.State([])
|
| 1264 |
+
applied_rules_state = gr.State([])
|
| 1265 |
+
rule_counter_state = gr.State(0)
|
| 1266 |
+
|
| 1267 |
+
gr.Markdown("# 📧 Email Rule Agent")
|
| 1268 |
+
gr.Markdown("*Intelligent email management powered by AI*")
|
| 1269 |
+
|
| 1270 |
+
# Add login choice
|
| 1271 |
+
with gr.Row(visible=True, elem_id="login_row") as login_row:
|
| 1272 |
+
with gr.Column():
|
| 1273 |
+
gr.Markdown("## Welcome! Choose how to get started:")
|
| 1274 |
+
with gr.Row():
|
| 1275 |
+
demo_btn = gr.Button("🎮 Try with Demo Data", variant="primary", scale=1)
|
| 1276 |
+
gmail_btn = gr.Button("📧 Login with Gmail *Work in Progress*", variant="secondary", scale=1)
|
| 1277 |
+
|
| 1278 |
+
# Loading indicator (initially hidden)
|
| 1279 |
+
with gr.Column(visible=False, elem_id="loading_indicator") as loading_indicator:
|
| 1280 |
+
gr.HTML("""
|
| 1281 |
+
<div style="text-align: center; padding: 50px;">
|
| 1282 |
+
<div style="display: inline-block; width: 50px; height: 50px; border: 5px solid #f3f3f3; border-radius: 50%; border-top: 5px solid #007bff; animation: spin 1s linear infinite;"></div>
|
| 1283 |
+
<h3 style="margin-top: 20px; color: #666;">Initializing Email Rule Agent...</h3>
|
| 1284 |
+
<p style="color: #999;">Connecting to backend and loading your emails...</p>
|
| 1285 |
+
</div>
|
| 1286 |
+
<style>
|
| 1287 |
+
@keyframes spin {
|
| 1288 |
+
0% { transform: rotate(0deg); }
|
| 1289 |
+
100% { transform: rotate(360deg); }
|
| 1290 |
+
}
|
| 1291 |
+
</style>
|
| 1292 |
+
""")
|
| 1293 |
+
|
| 1294 |
+
# Main interface (initially hidden)
|
| 1295 |
+
with gr.Column(visible=False, elem_id="main_interface") as main_interface:
|
| 1296 |
+
# Preview banner above all columns
|
| 1297 |
+
preview_banner = gr.HTML(create_preview_banner(0), elem_classes="compact-banner")
|
| 1298 |
+
|
| 1299 |
+
# 3 column layout
|
| 1300 |
+
with gr.Row(elem_classes="main-interface-row"):
|
| 1301 |
+
# Left column - Chat Interface (35%)
|
| 1302 |
+
with gr.Column(scale=35, min_width=300):
|
| 1303 |
+
gr.Markdown("### 💬 Chat", elem_classes="compact-header column-header-title")
|
| 1304 |
+
chatbot = gr.Chatbot(
|
| 1305 |
+
value=[],
|
| 1306 |
+
type="messages",
|
| 1307 |
+
height="40vh",
|
| 1308 |
+
render_markdown=True,
|
| 1309 |
+
sanitize_html=False, # Allow our custom HTML for tool messages
|
| 1310 |
+
elem_classes="compact-chat",
|
| 1311 |
+
label=None,
|
| 1312 |
+
show_label=False
|
| 1313 |
+
)
|
| 1314 |
+
|
| 1315 |
+
with gr.Row(elem_classes="chat-input-row"):
|
| 1316 |
+
msg_input = gr.Textbox(
|
| 1317 |
+
placeholder="Describe how you want to organize your emails...",
|
| 1318 |
+
container=False,
|
| 1319 |
+
elem_classes="compact-input",
|
| 1320 |
+
scale=8
|
| 1321 |
+
)
|
| 1322 |
+
send_btn = gr.Button("❯", scale=1, variant="primary", min_width=45)
|
| 1323 |
+
|
| 1324 |
+
# Quick action buttons with text
|
| 1325 |
+
with gr.Row(elem_classes="compact-buttons"):
|
| 1326 |
+
analyze_btn = gr.Button("🔍 Analyze Inbox *WIP*", scale=1, size="sm")
|
| 1327 |
+
draft_btn = gr.Button("📝 Draft Replies *WIP*", scale=1, size="sm")
|
| 1328 |
+
|
| 1329 |
+
# Middle column - Rules (25%)
|
| 1330 |
+
with gr.Column(scale=25, min_width=250):
|
| 1331 |
+
gr.Markdown("### 📋 Rules", elem_classes="compact-header column-header-title")
|
| 1332 |
+
|
| 1333 |
+
# Hidden status message (we'll show messages inline or as toasts)
|
| 1334 |
+
status_msg = gr.Textbox(
|
| 1335 |
+
value="",
|
| 1336 |
+
visible=False,
|
| 1337 |
+
elem_id="status_msg"
|
| 1338 |
+
)
|
| 1339 |
+
|
| 1340 |
+
# Hidden components for JavaScript interaction
|
| 1341 |
+
with gr.Row(visible=False):
|
| 1342 |
+
preview_btn = gr.Button("Preview", elem_id="hidden_preview_btn")
|
| 1343 |
+
accept_btn = gr.Button("Accept", elem_id="hidden_accept_btn")
|
| 1344 |
+
reject_btn = gr.Button("Reject", elem_id="hidden_reject_btn")
|
| 1345 |
+
exit_preview_btn = gr.Button("Exit Preview", elem_id="hidden_exit_preview_btn")
|
| 1346 |
+
run_rule_btn = gr.Button("Run Rule", elem_id="hidden_run_rule_btn")
|
| 1347 |
+
archive_rule_btn = gr.Button("Archive Rule", elem_id="hidden_archive_rule_btn")
|
| 1348 |
+
reactivate_rule_btn = gr.Button("Reactivate Rule", elem_id="hidden_reactivate_rule_btn")
|
| 1349 |
+
current_rule_id = gr.Textbox(visible=False, elem_id="current_rule_id")
|
| 1350 |
+
|
| 1351 |
+
# For email expansion
|
| 1352 |
+
expand_email_btn = gr.Button("Expand", elem_id="hidden_expand_email_btn")
|
| 1353 |
+
current_email_id_input = gr.Textbox(visible=False, elem_id="current_email_id_input")
|
| 1354 |
+
|
| 1355 |
+
# Rule cards display with scroll
|
| 1356 |
+
rule_cards = gr.HTML(
|
| 1357 |
+
create_interactive_rule_cards([]),
|
| 1358 |
+
elem_classes="compact-rules"
|
| 1359 |
+
)
|
| 1360 |
+
|
| 1361 |
+
# Right column - Email Display (40%)
|
| 1362 |
+
with gr.Column(scale=40, min_width=350):
|
| 1363 |
+
# Email header now at the top like other columns
|
| 1364 |
+
gr.Markdown("### 📧 Emails", elem_classes="compact-header column-header-title")
|
| 1365 |
+
|
| 1366 |
+
# Folder dropdown (separate row)
|
| 1367 |
+
with gr.Row(elem_classes="compact-controls"):
|
| 1368 |
+
folder_dropdown = gr.Dropdown(
|
| 1369 |
+
choices=["Inbox (0)"],
|
| 1370 |
+
value="Inbox (0)",
|
| 1371 |
+
container=False,
|
| 1372 |
+
scale=1,
|
| 1373 |
+
elem_classes="compact-dropdown"
|
| 1374 |
+
)
|
| 1375 |
+
|
| 1376 |
+
# Search and sort
|
| 1377 |
+
with gr.Row(elem_classes="compact-controls"):
|
| 1378 |
+
search_box = gr.Textbox(
|
| 1379 |
+
placeholder="Search...",
|
| 1380 |
+
container=False,
|
| 1381 |
+
scale=3,
|
| 1382 |
+
elem_classes="compact-search"
|
| 1383 |
+
)
|
| 1384 |
+
sort_dropdown = gr.Dropdown(
|
| 1385 |
+
choices=["Newest", "Oldest"],
|
| 1386 |
+
value="Newest",
|
| 1387 |
+
container=False,
|
| 1388 |
+
scale=1,
|
| 1389 |
+
elem_classes="compact-sort"
|
| 1390 |
+
)
|
| 1391 |
+
|
| 1392 |
+
# Email list with scroll
|
| 1393 |
+
email_display = gr.HTML(
|
| 1394 |
+
value=create_email_html([]),
|
| 1395 |
+
elem_classes="compact-emails"
|
| 1396 |
+
)
|
| 1397 |
+
|
| 1398 |
+
# Add Demo Checklist HTML (initially hidden)
|
| 1399 |
+
todo_list_html = gr.HTML("""
|
| 1400 |
+
<div class="demo-checklist" id="demo-checklist">
|
| 1401 |
+
<div class="checklist-header" onmousedown="startDrag(event)">
|
| 1402 |
+
<span>🚀 Demo Checklist</span>
|
| 1403 |
+
<span class="header-progress" id="header-progress"></span>
|
| 1404 |
+
<button class="minimize-btn" id="minimize-btn" onclick="toggleChecklist(event)">▼</button>
|
| 1405 |
+
</div>
|
| 1406 |
+
<div class="checklist-body">
|
| 1407 |
+
<div class="progress-section">
|
| 1408 |
+
<div class="progress-label">Progress: <span id="progress-text">0/5 completed</span></div>
|
| 1409 |
+
<div class="progress-bar">
|
| 1410 |
+
<div class="progress-fill" id="progress-fill"></div>
|
| 1411 |
+
</div>
|
| 1412 |
+
</div>
|
| 1413 |
+
<div class="checklist-items">
|
| 1414 |
+
<div class="checklist-item" data-action="manual-rule">
|
| 1415 |
+
<input type="checkbox" class="checklist-checkbox" onchange="updateProgress()">
|
| 1416 |
+
<span class="checklist-text">🚗 Create rule: "Move Uber receipts to travel folder"</span>
|
| 1417 |
+
</div>
|
| 1418 |
+
<div class="checklist-item" data-action="preview">
|
| 1419 |
+
<input type="checkbox" class="checklist-checkbox" onchange="updateProgress()">
|
| 1420 |
+
<span class="checklist-text">👁️ Preview the rule to see affected emails</span>
|
| 1421 |
+
</div>
|
| 1422 |
+
<div class="checklist-item" data-action="accept">
|
| 1423 |
+
<input type="checkbox" class="checklist-checkbox" onchange="updateProgress()">
|
| 1424 |
+
<span class="checklist-text">✅ Accept the rule and organize emails</span>
|
| 1425 |
+
</div>
|
| 1426 |
+
</div>
|
| 1427 |
+
</div>
|
| 1428 |
+
<div id="celebration-overlay" class="celebration-overlay hidden">
|
| 1429 |
+
<div class="celebration-content">
|
| 1430 |
+
<button class="celebration-close" onclick="dismissCelebration()">×</button>
|
| 1431 |
+
<h3 class="celebration-heading">🎉 All Done! 🎉</h3>
|
| 1432 |
+
<p class="celebration-message">Thank you for demoing our hackathon app!</p>
|
| 1433 |
+
</div>
|
| 1434 |
+
</div>
|
| 1435 |
+
</div>
|
| 1436 |
+
""", visible=False, elem_id="todo_list_html")
|
| 1437 |
+
|
| 1438 |
+
# Initialize data on load
|
| 1439 |
+
def initialize_app(session_id):
|
| 1440 |
+
"""Initialize app with data from MCP backend"""
|
| 1441 |
+
# Initialize MCP client with session
|
| 1442 |
+
mcp_client = MCPClient(modal_url, session_token=session_id)
|
| 1443 |
+
|
| 1444 |
+
# Fetch initial data from backend
|
| 1445 |
+
email_response = mcp_client.list_emails(folder='inbox', page_size=50)
|
| 1446 |
+
initial_rules = mcp_client.get_rules()
|
| 1447 |
+
|
| 1448 |
+
# Extract data from responses
|
| 1449 |
+
initial_emails = email_response.get('emails', [])
|
| 1450 |
+
|
| 1451 |
+
# For preview functionality, fetch a different set
|
| 1452 |
+
preview_response = mcp_client.list_emails(folder='inbox', page_size=20)
|
| 1453 |
+
preview_emails = preview_response.get('emails', [])
|
| 1454 |
+
|
| 1455 |
+
# Initialize rule counter based on existing rules
|
| 1456 |
+
rule_counter = len(initial_rules)
|
| 1457 |
+
|
| 1458 |
+
# Create welcome message
|
| 1459 |
+
welcome_chat = [{
|
| 1460 |
+
"role": "assistant",
|
| 1461 |
+
"content": """👋 Hi! I'm your Email Organization Assistant. I can help you:
|
| 1462 |
+
|
| 1463 |
+
• 📋 **Create smart filters** to automatically organize emails
|
| 1464 |
+
• 🏷️ **Set up labels** for different types of emails
|
| 1465 |
+
• 📁 **Move emails** to folders based on sender, subject, or content
|
| 1466 |
+
|
| 1467 |
+
Try clicking "Analyze" below or tell me how you'd like to organize your emails!"""
|
| 1468 |
+
}]
|
| 1469 |
+
|
| 1470 |
+
# Update UI components
|
| 1471 |
+
folder_choices = get_folder_dropdown_choices(initial_emails)
|
| 1472 |
+
initial_folder = folder_choices[0] if folder_choices else "Inbox (0)"
|
| 1473 |
+
initial_email_html = create_email_html(initial_emails)
|
| 1474 |
+
initial_rule_cards = create_interactive_rule_cards(initial_rules)
|
| 1475 |
+
initial_preview_banner = create_preview_banner(get_preview_rules_count(initial_rules))
|
| 1476 |
+
|
| 1477 |
+
return (
|
| 1478 |
+
mcp_client, # mcp_client_state
|
| 1479 |
+
initial_emails, # sample_emails_state
|
| 1480 |
+
preview_emails, # preview_emails_state
|
| 1481 |
+
initial_emails, # current_emails_state
|
| 1482 |
+
initial_rules, # pending_rules_state
|
| 1483 |
+
[], # applied_rules_state
|
| 1484 |
+
rule_counter, # rule_counter_state
|
| 1485 |
+
gr.update(choices=folder_choices, value=initial_folder), # folder_dropdown
|
| 1486 |
+
initial_email_html, # email_display
|
| 1487 |
+
initial_rule_cards, # rule_cards
|
| 1488 |
+
initial_preview_banner, # preview_banner
|
| 1489 |
+
welcome_chat, # chatbot initial value
|
| 1490 |
+
None # expanded_email_id_state
|
| 1491 |
+
)
|
| 1492 |
+
|
| 1493 |
+
# Event handlers
|
| 1494 |
+
def start_demo_mode(session_id):
|
| 1495 |
+
try:
|
| 1496 |
+
# Initialize and set session to demo mode
|
| 1497 |
+
results = initialize_app(session_id)
|
| 1498 |
+
mcp_client = results[0]
|
| 1499 |
+
mcp_client.set_mode('mock')
|
| 1500 |
+
return (
|
| 1501 |
+
*results,
|
| 1502 |
+
gr.update(visible=False), # login_row
|
| 1503 |
+
gr.update(visible=False), # loading_indicator
|
| 1504 |
+
gr.update(visible=True), # main_interface
|
| 1505 |
+
gr.update(visible=True) # todo_list_html
|
| 1506 |
+
)
|
| 1507 |
+
except Exception as e:
|
| 1508 |
+
print(f"Error initializing app: {e}")
|
| 1509 |
+
error_chat = [{
|
| 1510 |
+
"role": "assistant",
|
| 1511 |
+
"content": f"❌ **Failed to initialize the app**\n\nError: {str(e)}\n\nPlease try refreshing the page or running in local mode with `python app.py --local`"
|
| 1512 |
+
}]
|
| 1513 |
+
# Return minimal state with error message
|
| 1514 |
+
return (
|
| 1515 |
+
MCPClient('local://mock'), # mcp_client_state
|
| 1516 |
+
[], # sample_emails_state
|
| 1517 |
+
[], # preview_emails_state
|
| 1518 |
+
[], # current_emails_state
|
| 1519 |
+
[], # pending_rules_state
|
| 1520 |
+
[], # applied_rules_state
|
| 1521 |
+
0, # rule_counter_state
|
| 1522 |
+
gr.update(choices=["Inbox (0)"], value="Inbox (0)"), # folder_dropdown
|
| 1523 |
+
create_email_html([]), # email_display
|
| 1524 |
+
create_interactive_rule_cards([]), # rule_cards
|
| 1525 |
+
create_preview_banner(0), # preview_banner
|
| 1526 |
+
error_chat, # chatbot
|
| 1527 |
+
None, # expanded_email_id_state
|
| 1528 |
+
gr.update(visible=True), # login_row (show again)
|
| 1529 |
+
gr.update(visible=False), # loading_indicator
|
| 1530 |
+
gr.update(visible=False), # main_interface
|
| 1531 |
+
gr.update(visible=False) # todo_list_html
|
| 1532 |
+
)
|
| 1533 |
+
|
| 1534 |
+
def start_gmail_mode(session_id):
|
| 1535 |
+
try:
|
| 1536 |
+
# Initialize and get Gmail OAuth URL
|
| 1537 |
+
results = initialize_app(session_id)
|
| 1538 |
+
mcp_client = results[0]
|
| 1539 |
+
auth_url = mcp_client.get_gmail_auth_url()
|
| 1540 |
+
if auth_url:
|
| 1541 |
+
# In a real app, we'd redirect to this URL
|
| 1542 |
+
print(f"Gmail auth URL: {auth_url}")
|
| 1543 |
+
return (
|
| 1544 |
+
*results,
|
| 1545 |
+
gr.update(visible=False), # login_row
|
| 1546 |
+
gr.update(visible=False), # loading_indicator
|
| 1547 |
+
gr.update(visible=True), # main_interface
|
| 1548 |
+
gr.update(visible=True) # todo_list_html
|
| 1549 |
+
)
|
| 1550 |
+
except Exception as e:
|
| 1551 |
+
print(f"Error initializing Gmail mode: {e}")
|
| 1552 |
+
# Return same error state as demo mode
|
| 1553 |
+
error_chat = [{
|
| 1554 |
+
"role": "assistant",
|
| 1555 |
+
"content": f"❌ **Failed to initialize Gmail mode**\n\nError: {str(e)}\n\nPlease try the demo mode or check your connection."
|
| 1556 |
+
}]
|
| 1557 |
+
return (
|
| 1558 |
+
MCPClient('local://mock'), # mcp_client_state
|
| 1559 |
+
[], # sample_emails_state
|
| 1560 |
+
[], # preview_emails_state
|
| 1561 |
+
[], # current_emails_state
|
| 1562 |
+
[], # pending_rules_state
|
| 1563 |
+
[], # applied_rules_state
|
| 1564 |
+
0, # rule_counter_state
|
| 1565 |
+
gr.update(choices=["Inbox (0)"], value="Inbox (0)"), # folder_dropdown
|
| 1566 |
+
create_email_html([]), # email_display
|
| 1567 |
+
create_interactive_rule_cards([]), # rule_cards
|
| 1568 |
+
create_preview_banner(0), # preview_banner
|
| 1569 |
+
error_chat, # chatbot
|
| 1570 |
+
None, # expanded_email_id_state
|
| 1571 |
+
gr.update(visible=True), # login_row (show again)
|
| 1572 |
+
gr.update(visible=False), # loading_indicator
|
| 1573 |
+
gr.update(visible=False), # main_interface
|
| 1574 |
+
gr.update(visible=False) # todo_list_html
|
| 1575 |
+
)
|
| 1576 |
+
|
| 1577 |
+
# Show loading function
|
| 1578 |
+
def show_loading():
|
| 1579 |
+
return (
|
| 1580 |
+
gr.update(visible=False), # login_row
|
| 1581 |
+
gr.update(visible=True) # loading_indicator
|
| 1582 |
+
)
|
| 1583 |
+
|
| 1584 |
+
demo_btn.click(
|
| 1585 |
+
show_loading,
|
| 1586 |
+
inputs=None,
|
| 1587 |
+
outputs=[login_row, loading_indicator],
|
| 1588 |
+
queue=False
|
| 1589 |
+
).then(
|
| 1590 |
+
start_demo_mode,
|
| 1591 |
+
inputs=[session_id],
|
| 1592 |
+
outputs=[
|
| 1593 |
+
mcp_client_state, sample_emails_state, preview_emails_state, current_emails_state,
|
| 1594 |
+
pending_rules_state, applied_rules_state, rule_counter_state,
|
| 1595 |
+
folder_dropdown, email_display, rule_cards, preview_banner, chatbot,
|
| 1596 |
+
expanded_email_id_state,
|
| 1597 |
+
login_row, loading_indicator, main_interface, todo_list_html
|
| 1598 |
+
]
|
| 1599 |
+
).then(
|
| 1600 |
+
None,
|
| 1601 |
+
None,
|
| 1602 |
+
None,
|
| 1603 |
+
js="() => { setTimeout(() => window.showDemoChecklist(), 100); }"
|
| 1604 |
+
)
|
| 1605 |
+
|
| 1606 |
+
gmail_btn.click(
|
| 1607 |
+
show_loading,
|
| 1608 |
+
inputs=None,
|
| 1609 |
+
outputs=[login_row, loading_indicator],
|
| 1610 |
+
queue=False
|
| 1611 |
+
).then(
|
| 1612 |
+
start_gmail_mode,
|
| 1613 |
+
inputs=[session_id],
|
| 1614 |
+
outputs=[
|
| 1615 |
+
mcp_client_state, sample_emails_state, preview_emails_state, current_emails_state,
|
| 1616 |
+
pending_rules_state, applied_rules_state, rule_counter_state,
|
| 1617 |
+
folder_dropdown, email_display, rule_cards, preview_banner, chatbot,
|
| 1618 |
+
expanded_email_id_state,
|
| 1619 |
+
login_row, loading_indicator, main_interface, todo_list_html
|
| 1620 |
+
]
|
| 1621 |
+
)
|
| 1622 |
+
|
| 1623 |
+
# Use the streaming chat handler
|
| 1624 |
+
|
| 1625 |
+
send_btn.click(
|
| 1626 |
+
process_chat_streaming,
|
| 1627 |
+
inputs=[msg_input, chatbot, mcp_client_state, current_emails_state,
|
| 1628 |
+
pending_rules_state, applied_rules_state, rule_counter_state],
|
| 1629 |
+
outputs=[chatbot, msg_input, rule_cards, status_msg,
|
| 1630 |
+
pending_rules_state, rule_counter_state],
|
| 1631 |
+
show_progress="full" # Show progress for streaming
|
| 1632 |
+
)
|
| 1633 |
+
|
| 1634 |
+
msg_input.submit(
|
| 1635 |
+
process_chat_streaming,
|
| 1636 |
+
inputs=[msg_input, chatbot, mcp_client_state, current_emails_state,
|
| 1637 |
+
pending_rules_state, applied_rules_state, rule_counter_state],
|
| 1638 |
+
outputs=[chatbot, msg_input, rule_cards, status_msg,
|
| 1639 |
+
pending_rules_state, rule_counter_state],
|
| 1640 |
+
show_progress="full"
|
| 1641 |
+
)
|
| 1642 |
+
|
| 1643 |
+
# Quick action handlers with streaming
|
| 1644 |
+
def analyze_inbox(hist, mcp, emails, pend, appl, cnt):
|
| 1645 |
+
yield from process_chat_streaming(
|
| 1646 |
+
"Analyze my inbox and suggest organization rules",
|
| 1647 |
+
hist, mcp, emails, pend, appl, cnt
|
| 1648 |
+
)
|
| 1649 |
+
|
| 1650 |
+
analyze_btn.click(
|
| 1651 |
+
analyze_inbox,
|
| 1652 |
+
inputs=[chatbot, mcp_client_state, current_emails_state,
|
| 1653 |
+
pending_rules_state, applied_rules_state, rule_counter_state],
|
| 1654 |
+
outputs=[chatbot, msg_input, rule_cards, status_msg,
|
| 1655 |
+
pending_rules_state, rule_counter_state],
|
| 1656 |
+
show_progress="full"
|
| 1657 |
+
)
|
| 1658 |
+
|
| 1659 |
+
def draft_replies(hist, mcp, emails, pend, appl, cnt):
|
| 1660 |
+
yield from process_chat_streaming(
|
| 1661 |
+
"Draft polite replies to all meeting invitations saying I'll check my calendar and get back to them",
|
| 1662 |
+
hist, mcp, emails, pend, appl, cnt
|
| 1663 |
+
)
|
| 1664 |
+
|
| 1665 |
+
draft_btn.click(
|
| 1666 |
+
draft_replies,
|
| 1667 |
+
inputs=[chatbot, mcp_client_state, current_emails_state,
|
| 1668 |
+
pending_rules_state, applied_rules_state, rule_counter_state],
|
| 1669 |
+
outputs=[chatbot, msg_input, rule_cards, status_msg,
|
| 1670 |
+
pending_rules_state, rule_counter_state],
|
| 1671 |
+
show_progress="full"
|
| 1672 |
+
)
|
| 1673 |
+
|
| 1674 |
+
# Email display handlers
|
| 1675 |
+
def update_emails(folder, sort_option, search_query, current_emails):
|
| 1676 |
+
filtered_emails = filter_emails(folder, search_query, sort_option, current_emails)
|
| 1677 |
+
return create_email_html(filtered_emails, folder)
|
| 1678 |
+
|
| 1679 |
+
folder_dropdown.change(
|
| 1680 |
+
update_emails,
|
| 1681 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, current_emails_state],
|
| 1682 |
+
outputs=[email_display]
|
| 1683 |
+
)
|
| 1684 |
+
|
| 1685 |
+
sort_dropdown.change(
|
| 1686 |
+
lambda folder, sort, search, emails: update_emails(
|
| 1687 |
+
folder,
|
| 1688 |
+
"Newest First" if sort == "Newest" else "Oldest First",
|
| 1689 |
+
search,
|
| 1690 |
+
emails
|
| 1691 |
+
),
|
| 1692 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, current_emails_state],
|
| 1693 |
+
outputs=[email_display]
|
| 1694 |
+
)
|
| 1695 |
+
|
| 1696 |
+
search_box.change(
|
| 1697 |
+
update_emails,
|
| 1698 |
+
inputs=[folder_dropdown, sort_dropdown, search_box, current_emails_state],
|
| 1699 |
+
outputs=[email_display]
|
| 1700 |
+
)
|
| 1701 |
+
|
| 1702 |
+
# Rule action handlers
|
| 1703 |
+
preview_btn.click(
|
| 1704 |
+
handle_preview_rule,
|
| 1705 |
+
inputs=[current_rule_id, sort_dropdown, search_box, mcp_client_state,
|
| 1706 |
+
pending_rules_state, current_emails_state, sample_emails_state, preview_emails_state],
|
| 1707 |
+
outputs=[folder_dropdown, email_display, rule_cards, preview_banner, status_msg,
|
| 1708 |
+
pending_rules_state, current_emails_state]
|
| 1709 |
+
)
|
| 1710 |
+
|
| 1711 |
+
accept_btn.click(
|
| 1712 |
+
lambda rule_id, folder, sort, search, mcp, pend, appl, curr, samp: handle_accept_rule(
|
| 1713 |
+
rule_id, mcp, folder, sort, search, pend, appl, curr, samp),
|
| 1714 |
+
inputs=[current_rule_id, folder_dropdown, sort_dropdown, search_box, mcp_client_state,
|
| 1715 |
+
pending_rules_state, applied_rules_state, current_emails_state, sample_emails_state],
|
| 1716 |
+
outputs=[rule_cards, preview_banner, status_msg, email_display, folder_dropdown,
|
| 1717 |
+
pending_rules_state, applied_rules_state, current_emails_state, sample_emails_state]
|
| 1718 |
+
)
|
| 1719 |
+
|
| 1720 |
+
reject_btn.click(
|
| 1721 |
+
handle_archive_rule,
|
| 1722 |
+
inputs=[current_rule_id, mcp_client_state, pending_rules_state],
|
| 1723 |
+
outputs=[rule_cards, preview_banner, status_msg, pending_rules_state]
|
| 1724 |
+
)
|
| 1725 |
+
|
| 1726 |
+
exit_preview_btn.click(
|
| 1727 |
+
lambda sort, search, pend, curr, samp: exit_preview_mode(sort, search, pend, curr, samp),
|
| 1728 |
+
inputs=[sort_dropdown, search_box, pending_rules_state, current_emails_state, sample_emails_state],
|
| 1729 |
+
outputs=[folder_dropdown, email_display, rule_cards, preview_banner, status_msg,
|
| 1730 |
+
pending_rules_state, current_emails_state]
|
| 1731 |
+
)
|
| 1732 |
+
|
| 1733 |
+
run_rule_btn.click(
|
| 1734 |
+
lambda rule_id, folder, sort, search, mcp, pend, appl, curr, samp: handle_run_rule(
|
| 1735 |
+
rule_id, mcp, folder, sort, search, pend, appl, curr, samp),
|
| 1736 |
+
inputs=[current_rule_id, folder_dropdown, sort_dropdown, search_box, mcp_client_state,
|
| 1737 |
+
pending_rules_state, applied_rules_state, current_emails_state, sample_emails_state],
|
| 1738 |
+
outputs=[rule_cards, preview_banner, status_msg, email_display, folder_dropdown,
|
| 1739 |
+
pending_rules_state, applied_rules_state, current_emails_state, sample_emails_state]
|
| 1740 |
+
)
|
| 1741 |
+
|
| 1742 |
+
archive_rule_btn.click(
|
| 1743 |
+
handle_archive_rule,
|
| 1744 |
+
inputs=[current_rule_id, mcp_client_state, pending_rules_state],
|
| 1745 |
+
outputs=[rule_cards, preview_banner, status_msg, pending_rules_state]
|
| 1746 |
+
)
|
| 1747 |
+
|
| 1748 |
+
reactivate_rule_btn.click(
|
| 1749 |
+
handle_reactivate_rule,
|
| 1750 |
+
inputs=[current_rule_id, mcp_client_state, pending_rules_state],
|
| 1751 |
+
outputs=[rule_cards, preview_banner, status_msg, pending_rules_state]
|
| 1752 |
+
)
|
| 1753 |
+
|
| 1754 |
+
# Email expansion handler
|
| 1755 |
+
def toggle_email_expansion_handler(email_id, folder, sort, search, current_emails, current_expanded_id):
|
| 1756 |
+
new_expanded_id = email_id if email_id != current_expanded_id else None
|
| 1757 |
+
filtered_emails = filter_emails(folder, search, sort, current_emails)
|
| 1758 |
+
html = create_email_html(filtered_emails, folder, expanded_email_id=new_expanded_id)
|
| 1759 |
+
return html, new_expanded_id
|
| 1760 |
+
|
| 1761 |
+
expand_email_btn.click(
|
| 1762 |
+
toggle_email_expansion_handler,
|
| 1763 |
+
inputs=[current_email_id_input, folder_dropdown, sort_dropdown, search_box, current_emails_state, expanded_email_id_state],
|
| 1764 |
+
outputs=[email_display, expanded_email_id_state]
|
| 1765 |
+
)
|
| 1766 |
+
|
| 1767 |
+
# Load JavaScript handlers
|
| 1768 |
+
demo.load(
|
| 1769 |
+
None,
|
| 1770 |
+
inputs=None,
|
| 1771 |
+
outputs=None,
|
| 1772 |
+
js=get_javascript_handlers()
|
| 1773 |
+
)
|
| 1774 |
+
|
| 1775 |
+
return demo
|
| 1776 |
+
|
| 1777 |
+
|
| 1778 |
+
def launch_app(demo: gr.Blocks):
|
| 1779 |
+
"""Launch the Gradio app"""
|
| 1780 |
+
demo.launch()
|
| 1781 |
+
|
| 1782 |
+
|
| 1783 |
+
def get_javascript_handlers():
|
| 1784 |
+
"""Get JavaScript code for handling UI interactions"""
|
| 1785 |
+
return """
|
| 1786 |
+
() => {
|
| 1787 |
+
// Load Toastify.js dynamically
|
| 1788 |
+
const script = document.createElement('script');
|
| 1789 |
+
script.src = 'https://cdn.jsdelivr.net/npm/toastify-js';
|
| 1790 |
+
document.head.appendChild(script);
|
| 1791 |
+
|
| 1792 |
+
// Toast notification function
|
| 1793 |
+
window.showToast = function(message, type = 'info') {
|
| 1794 |
+
if (!window.Toastify) {
|
| 1795 |
+
console.log('Toast:', message);
|
| 1796 |
+
return;
|
| 1797 |
+
}
|
| 1798 |
+
|
| 1799 |
+
let backgroundColor = "linear-gradient(to right, #00b09b, #96c93d)"; // Default success
|
| 1800 |
+
|
| 1801 |
+
// Determine color based on message content or type
|
| 1802 |
+
if (message.startsWith("❌") || message.toLowerCase().includes("error") || type === 'error') {
|
| 1803 |
+
backgroundColor = "linear-gradient(to right, #ff5f6d, #ffc371)"; // Error
|
| 1804 |
+
} else if (message.startsWith("⚠️") || message.startsWith("👁️") || type === 'warning') {
|
| 1805 |
+
backgroundColor = "linear-gradient(to right, #ffd166, #f77f00)"; // Warning
|
| 1806 |
+
} else if (message.startsWith("✅") || message.startsWith("✓") || type === 'success') {
|
| 1807 |
+
backgroundColor = "linear-gradient(to right, #00b09b, #96c93d)"; // Success
|
| 1808 |
+
} else if (message.startsWith("🗄️") || message.startsWith("🔄") || type === 'info') {
|
| 1809 |
+
backgroundColor = "linear-gradient(to right, #667eea, #764ba2)"; // Info
|
| 1810 |
+
}
|
| 1811 |
+
|
| 1812 |
+
Toastify({
|
| 1813 |
+
text: message,
|
| 1814 |
+
duration: 3000,
|
| 1815 |
+
close: true,
|
| 1816 |
+
gravity: "top",
|
| 1817 |
+
position: "right",
|
| 1818 |
+
stopOnFocus: true,
|
| 1819 |
+
style: {
|
| 1820 |
+
background: backgroundColor,
|
| 1821 |
+
},
|
| 1822 |
+
onClick: function(){} // Callback after click
|
| 1823 |
+
}).showToast();
|
| 1824 |
+
};
|
| 1825 |
+
|
| 1826 |
+
// Set up observer for status_msg changes
|
| 1827 |
+
setTimeout(() => {
|
| 1828 |
+
const statusContainer = document.getElementById('status_msg');
|
| 1829 |
+
if (statusContainer) {
|
| 1830 |
+
const statusTextarea = statusContainer.querySelector('textarea');
|
| 1831 |
+
if (statusTextarea) {
|
| 1832 |
+
// Previous value to detect changes
|
| 1833 |
+
let previousValue = statusTextarea.value;
|
| 1834 |
+
|
| 1835 |
+
// List of status messages to ignore (agent typing/processing messages)
|
| 1836 |
+
const ignoredStatuses = [
|
| 1837 |
+
'Processing...',
|
| 1838 |
+
'Typing...',
|
| 1839 |
+
'Analyzing emails...',
|
| 1840 |
+
'Creating rules...',
|
| 1841 |
+
'Ready...',
|
| 1842 |
+
'Complete',
|
| 1843 |
+
'API key missing'
|
| 1844 |
+
];
|
| 1845 |
+
|
| 1846 |
+
// Track if we've shown a toast recently to prevent duplicates
|
| 1847 |
+
let lastToastTime = 0;
|
| 1848 |
+
const TOAST_DEBOUNCE = 500; // 500ms between toasts
|
| 1849 |
+
|
| 1850 |
+
// Function to handle status changes
|
| 1851 |
+
const handleStatusChange = () => {
|
| 1852 |
+
const currentValue = statusTextarea.value;
|
| 1853 |
+
const now = Date.now();
|
| 1854 |
+
|
| 1855 |
+
if (currentValue && currentValue.trim() && currentValue !== previousValue) {
|
| 1856 |
+
// Check if we've shown a toast recently
|
| 1857 |
+
if (now - lastToastTime < TOAST_DEBOUNCE) {
|
| 1858 |
+
return;
|
| 1859 |
+
}
|
| 1860 |
+
|
| 1861 |
+
// Don't show toast for agent typing/processing messages
|
| 1862 |
+
const isIgnored = ignoredStatuses.some(status =>
|
| 1863 |
+
currentValue === status || currentValue.startsWith(status)
|
| 1864 |
+
);
|
| 1865 |
+
|
| 1866 |
+
// Only show toast for actual status updates (errors, rule actions, etc.)
|
| 1867 |
+
if (!isIgnored && !currentValue.includes('Found') && !currentValue.includes('rules')) {
|
| 1868 |
+
window.showToast(currentValue);
|
| 1869 |
+
lastToastTime = now;
|
| 1870 |
+
}
|
| 1871 |
+
|
| 1872 |
+
previousValue = currentValue;
|
| 1873 |
+
// Clear after showing toast
|
| 1874 |
+
setTimeout(() => {
|
| 1875 |
+
statusTextarea.value = '';
|
| 1876 |
+
previousValue = '';
|
| 1877 |
+
}, 200);
|
| 1878 |
+
}
|
| 1879 |
+
};
|
| 1880 |
+
|
| 1881 |
+
// Listen for input events
|
| 1882 |
+
statusTextarea.addEventListener('input', handleStatusChange);
|
| 1883 |
+
|
| 1884 |
+
// Also check periodically as backup (less frequent)
|
| 1885 |
+
setInterval(handleStatusChange, 300);
|
| 1886 |
+
|
| 1887 |
+
console.log('Toast notification system initialized');
|
| 1888 |
+
} else {
|
| 1889 |
+
console.error('Status textarea not found');
|
| 1890 |
+
}
|
| 1891 |
+
} else {
|
| 1892 |
+
console.error('Status container not found');
|
| 1893 |
+
}
|
| 1894 |
+
}, 1000);
|
| 1895 |
+
|
| 1896 |
+
// Expose email handler to global scope
|
| 1897 |
+
window.handleEmailAction = (emailId) => {
|
| 1898 |
+
console.log(`handleEmailAction called with emailId: ${emailId}`);
|
| 1899 |
+
|
| 1900 |
+
const container = document.getElementById('current_email_id_input');
|
| 1901 |
+
let emailIdInput = null;
|
| 1902 |
+
if (container) {
|
| 1903 |
+
emailIdInput = container.querySelector('input, textarea');
|
| 1904 |
+
}
|
| 1905 |
+
|
| 1906 |
+
if (emailIdInput) {
|
| 1907 |
+
emailIdInput.value = emailId;
|
| 1908 |
+
emailIdInput.dispatchEvent(new Event('input', { bubbles: true }));
|
| 1909 |
+
emailIdInput.dispatchEvent(new Event('change', { bubbles: true }));
|
| 1910 |
+
|
| 1911 |
+
setTimeout(() => {
|
| 1912 |
+
const targetButton = document.getElementById('hidden_expand_email_btn');
|
| 1913 |
+
if (targetButton) {
|
| 1914 |
+
targetButton.click();
|
| 1915 |
+
} else {
|
| 1916 |
+
console.error('Hidden expand email button not found');
|
| 1917 |
+
}
|
| 1918 |
+
}, 100);
|
| 1919 |
+
} else {
|
| 1920 |
+
console.error('Email ID input field not found');
|
| 1921 |
+
}
|
| 1922 |
+
};
|
| 1923 |
+
|
| 1924 |
+
// Set up global click handler once
|
| 1925 |
+
console.log('Setting up global click handler');
|
| 1926 |
+
document.addEventListener('click', globalClickHandler);
|
| 1927 |
+
|
| 1928 |
+
function globalClickHandler(e) {
|
| 1929 |
+
// Use a single selector for all action buttons
|
| 1930 |
+
const actionBtn = e.target.closest('.preview-btn, .accept-btn, .reject-btn, .run-btn, .archive-btn, .reactivate-btn');
|
| 1931 |
+
|
| 1932 |
+
if (actionBtn) {
|
| 1933 |
+
e.preventDefault();
|
| 1934 |
+
e.stopPropagation(); // Stop propagation AFTER we handle it
|
| 1935 |
+
|
| 1936 |
+
// Extract action from class name (skip 'rule-btn')
|
| 1937 |
+
const action = Array.from(actionBtn.classList)
|
| 1938 |
+
.find(c => c.endsWith('-btn') && c !== 'rule-btn')
|
| 1939 |
+
.replace('-btn', '');
|
| 1940 |
+
|
| 1941 |
+
const ruleId = actionBtn.getAttribute('data-rule-id');
|
| 1942 |
+
console.log(`${action} button clicked for rule: ${ruleId}`);
|
| 1943 |
+
|
| 1944 |
+
handleRuleAction(action, ruleId);
|
| 1945 |
+
} else if (e.target.closest('#exit-preview-btn, .exit-preview-card-btn')) {
|
| 1946 |
+
e.preventDefault();
|
| 1947 |
+
e.stopPropagation();
|
| 1948 |
+
handleExitPreview();
|
| 1949 |
+
}
|
| 1950 |
+
}
|
| 1951 |
+
|
| 1952 |
+
function handleExitPreview() {
|
| 1953 |
+
console.log('Exit preview button clicked');
|
| 1954 |
+
const exitButton = document.getElementById('hidden_exit_preview_btn');
|
| 1955 |
+
if (exitButton) {
|
| 1956 |
+
exitButton.click();
|
| 1957 |
+
} else {
|
| 1958 |
+
console.error('Hidden exit preview button not found');
|
| 1959 |
+
}
|
| 1960 |
+
}
|
| 1961 |
+
|
| 1962 |
+
function handleRuleAction(action, ruleId) {
|
| 1963 |
+
console.log(`handleRuleAction called with action: ${action}, ruleId: ${ruleId}`);
|
| 1964 |
+
|
| 1965 |
+
if (!ruleId) {
|
| 1966 |
+
console.error('No ruleId provided');
|
| 1967 |
+
window.showToast('❌ No rule ID provided');
|
| 1968 |
+
return;
|
| 1969 |
+
}
|
| 1970 |
+
|
| 1971 |
+
try {
|
| 1972 |
+
// Find the hidden textbox
|
| 1973 |
+
const container = document.getElementById('current_rule_id');
|
| 1974 |
+
console.log('Found container:', container);
|
| 1975 |
+
|
| 1976 |
+
let ruleIdInput = null;
|
| 1977 |
+
|
| 1978 |
+
if (container) {
|
| 1979 |
+
ruleIdInput = container.querySelector('input, textarea');
|
| 1980 |
+
console.log('Found input element:', ruleIdInput);
|
| 1981 |
+
}
|
| 1982 |
+
|
| 1983 |
+
if (ruleIdInput) {
|
| 1984 |
+
console.log(`Setting rule ID input to: ${ruleId}`);
|
| 1985 |
+
ruleIdInput.value = ruleId;
|
| 1986 |
+
|
| 1987 |
+
// Force Gradio to recognize the change
|
| 1988 |
+
ruleIdInput.dispatchEvent(new Event('input', { bubbles: true }));
|
| 1989 |
+
ruleIdInput.dispatchEvent(new Event('change', { bubbles: true }));
|
| 1990 |
+
|
| 1991 |
+
// Add visual feedback
|
| 1992 |
+
if (action === 'preview') {
|
| 1993 |
+
window.showToast('👁️ Loading preview...');
|
| 1994 |
+
}
|
| 1995 |
+
|
| 1996 |
+
// Trigger the appropriate button after a delay
|
| 1997 |
+
setTimeout(() => {
|
| 1998 |
+
let targetButton;
|
| 1999 |
+
if (action === 'preview') {
|
| 2000 |
+
targetButton = document.getElementById('hidden_preview_btn');
|
| 2001 |
+
} else if (action === 'accept') {
|
| 2002 |
+
targetButton = document.getElementById('hidden_accept_btn');
|
| 2003 |
+
} else if (action === 'reject') {
|
| 2004 |
+
targetButton = document.getElementById('hidden_reject_btn');
|
| 2005 |
+
} else if (action === 'run') {
|
| 2006 |
+
targetButton = document.getElementById('hidden_run_rule_btn');
|
| 2007 |
+
} else if (action === 'archive') {
|
| 2008 |
+
targetButton = document.getElementById('hidden_archive_rule_btn');
|
| 2009 |
+
} else if (action === 'reactivate') {
|
| 2010 |
+
targetButton = document.getElementById('hidden_reactivate_rule_btn');
|
| 2011 |
+
}
|
| 2012 |
+
|
| 2013 |
+
console.log(`Looking for button: hidden_${action}_btn`);
|
| 2014 |
+
console.log('Found button:', targetButton);
|
| 2015 |
+
|
| 2016 |
+
if (targetButton) {
|
| 2017 |
+
console.log(`Clicking ${action} button`);
|
| 2018 |
+
targetButton.click();
|
| 2019 |
+
} else {
|
| 2020 |
+
console.error(`Target button not found for action: ${action}`);
|
| 2021 |
+
// List all buttons with IDs containing 'hidden' for debugging
|
| 2022 |
+
const allHiddenButtons = document.querySelectorAll('[id*="hidden"]');
|
| 2023 |
+
console.log('Available hidden elements:', Array.from(allHiddenButtons).map(el => el.id));
|
| 2024 |
+
window.showToast(`❌ Button not found: ${action}`);
|
| 2025 |
+
}
|
| 2026 |
+
}, 150);
|
| 2027 |
+
} else {
|
| 2028 |
+
console.error('Rule ID input field not found');
|
| 2029 |
+
window.showToast('❌ Rule ID input not found');
|
| 2030 |
+
}
|
| 2031 |
+
} catch (error) {
|
| 2032 |
+
console.error('Error in handleRuleAction:', error);
|
| 2033 |
+
window.showToast('❌ Error: ' + error.message);
|
| 2034 |
+
}
|
| 2035 |
+
}
|
| 2036 |
+
|
| 2037 |
+
// No need for MutationObserver - event delegation handles dynamic content
|
| 2038 |
+
|
| 2039 |
+
// Rule card expansion
|
| 2040 |
+
window.toggleRuleDetails = function(ruleId) {
|
| 2041 |
+
const detailsDiv = document.getElementById('details-' + ruleId);
|
| 2042 |
+
const arrow = document.getElementById('arrow-' + ruleId);
|
| 2043 |
+
|
| 2044 |
+
if (detailsDiv && arrow) {
|
| 2045 |
+
if (detailsDiv.style.display === 'none') {
|
| 2046 |
+
// Collapse all other rules first
|
| 2047 |
+
document.querySelectorAll('.rule-details').forEach(d => {
|
| 2048 |
+
if (d.id !== 'details-' + ruleId) {
|
| 2049 |
+
d.style.display = 'none';
|
| 2050 |
+
}
|
| 2051 |
+
});
|
| 2052 |
+
document.querySelectorAll('.rule-expand-arrow').forEach(a => {
|
| 2053 |
+
if (a.id !== 'arrow-' + ruleId) {
|
| 2054 |
+
a.classList.remove('expanded');
|
| 2055 |
+
}
|
| 2056 |
+
});
|
| 2057 |
+
|
| 2058 |
+
// Expand this rule
|
| 2059 |
+
detailsDiv.style.display = 'block';
|
| 2060 |
+
arrow.classList.add('expanded');
|
| 2061 |
+
} else {
|
| 2062 |
+
// Collapse this rule
|
| 2063 |
+
detailsDiv.style.display = 'none';
|
| 2064 |
+
arrow.classList.remove('expanded');
|
| 2065 |
+
}
|
| 2066 |
+
}
|
| 2067 |
+
};
|
| 2068 |
+
|
| 2069 |
+
// Demo Checklist Functions
|
| 2070 |
+
window.toggleChecklist = function(e) {
|
| 2071 |
+
if (e) {
|
| 2072 |
+
e.stopPropagation();
|
| 2073 |
+
}
|
| 2074 |
+
const checklist = document.getElementById('demo-checklist');
|
| 2075 |
+
const minimizeBtn = document.getElementById('minimize-btn');
|
| 2076 |
+
|
| 2077 |
+
if (checklist) {
|
| 2078 |
+
checklist.classList.toggle('minimized');
|
| 2079 |
+
minimizeBtn.textContent = checklist.classList.contains('minimized') ? '▲' : '▼';
|
| 2080 |
+
}
|
| 2081 |
+
};
|
| 2082 |
+
|
| 2083 |
+
window.updateProgress = function() {
|
| 2084 |
+
const checkboxes = document.querySelectorAll('.checklist-checkbox');
|
| 2085 |
+
const items = document.querySelectorAll('.checklist-item');
|
| 2086 |
+
const progressFill = document.getElementById('progress-fill');
|
| 2087 |
+
const progressText = document.getElementById('progress-text');
|
| 2088 |
+
const headerProgress = document.getElementById('header-progress');
|
| 2089 |
+
|
| 2090 |
+
let checked = 0;
|
| 2091 |
+
checkboxes.forEach((cb, index) => {
|
| 2092 |
+
if (cb.checked) {
|
| 2093 |
+
checked++;
|
| 2094 |
+
items[index].classList.add('completed');
|
| 2095 |
+
} else {
|
| 2096 |
+
items[index].classList.remove('completed');
|
| 2097 |
+
}
|
| 2098 |
+
});
|
| 2099 |
+
|
| 2100 |
+
const percentage = (checked / checkboxes.length) * 100;
|
| 2101 |
+
progressFill.style.width = percentage + '%';
|
| 2102 |
+
progressText.textContent = `${checked}/${checkboxes.length} completed`;
|
| 2103 |
+
|
| 2104 |
+
// Update header progress for collapsed state
|
| 2105 |
+
headerProgress.textContent = `(${checked}/5)`;
|
| 2106 |
+
|
| 2107 |
+
// Check if transitioning to/from complete state
|
| 2108 |
+
const wasComplete = document.body.dataset.checklistComplete === 'true';
|
| 2109 |
+
const isNowComplete = checked === checkboxes.length;
|
| 2110 |
+
|
| 2111 |
+
if (isNowComplete && !wasComplete) {
|
| 2112 |
+
// Show celebration
|
| 2113 |
+
showCelebration();
|
| 2114 |
+
document.body.dataset.checklistComplete = 'true';
|
| 2115 |
+
} else if (!isNowComplete && wasComplete) {
|
| 2116 |
+
// Hide celebration if unchecking
|
| 2117 |
+
dismissCelebration();
|
| 2118 |
+
document.body.dataset.checklistComplete = 'false';
|
| 2119 |
+
}
|
| 2120 |
+
};
|
| 2121 |
+
|
| 2122 |
+
// Auto-show checklist in demo mode
|
| 2123 |
+
window.showDemoChecklist = function() {
|
| 2124 |
+
const checklist = document.getElementById('demo-checklist');
|
| 2125 |
+
if (checklist) {
|
| 2126 |
+
checklist.classList.add('show', 'initial-show');
|
| 2127 |
+
|
| 2128 |
+
// Remove initial-show class after animation completes
|
| 2129 |
+
setTimeout(() => {
|
| 2130 |
+
checklist.classList.remove('initial-show');
|
| 2131 |
+
}, 500);
|
| 2132 |
+
|
| 2133 |
+
// Initialize progress display
|
| 2134 |
+
updateProgress();
|
| 2135 |
+
}
|
| 2136 |
+
};
|
| 2137 |
+
|
| 2138 |
+
window.showCelebration = function() {
|
| 2139 |
+
const overlay = document.getElementById('celebration-overlay');
|
| 2140 |
+
if (overlay) {
|
| 2141 |
+
overlay.classList.remove('hidden');
|
| 2142 |
+
// Also pulse the checklist container using CSS class
|
| 2143 |
+
const checklist = document.getElementById('demo-checklist');
|
| 2144 |
+
checklist.classList.add('pulse-animation');
|
| 2145 |
+
setTimeout(() => {
|
| 2146 |
+
checklist.classList.remove('pulse-animation');
|
| 2147 |
+
}, 1500);
|
| 2148 |
+
}
|
| 2149 |
+
};
|
| 2150 |
+
|
| 2151 |
+
window.dismissCelebration = function() {
|
| 2152 |
+
const overlay = document.getElementById('celebration-overlay');
|
| 2153 |
+
if (overlay) {
|
| 2154 |
+
overlay.classList.add('hidden');
|
| 2155 |
+
}
|
| 2156 |
+
};
|
| 2157 |
+
|
| 2158 |
+
// Click handler for checklist items (optional: track what users try)
|
| 2159 |
+
document.addEventListener('click', function(e) {
|
| 2160 |
+
if (e.target.classList.contains('checklist-text')) {
|
| 2161 |
+
const checkbox = e.target.previousElementSibling;
|
| 2162 |
+
if (checkbox && checkbox.type === 'checkbox') {
|
| 2163 |
+
checkbox.checked = !checkbox.checked;
|
| 2164 |
+
updateProgress();
|
| 2165 |
+
}
|
| 2166 |
+
}
|
| 2167 |
+
});
|
| 2168 |
+
|
| 2169 |
+
// Draggable functionality for Demo Checklist
|
| 2170 |
+
let isDragging = false;
|
| 2171 |
+
let currentX;
|
| 2172 |
+
let currentY;
|
| 2173 |
+
let initialX;
|
| 2174 |
+
let initialY;
|
| 2175 |
+
let xOffset = 0;
|
| 2176 |
+
let yOffset = 0;
|
| 2177 |
+
|
| 2178 |
+
window.startDrag = function(e) {
|
| 2179 |
+
const checklist = document.getElementById('demo-checklist');
|
| 2180 |
+
|
| 2181 |
+
// Don't start drag if clicking the minimize button
|
| 2182 |
+
if (e.target.classList.contains('minimize-btn')) {
|
| 2183 |
+
return;
|
| 2184 |
+
}
|
| 2185 |
+
|
| 2186 |
+
if (e.type === "touchstart") {
|
| 2187 |
+
initialX = e.touches[0].clientX - xOffset;
|
| 2188 |
+
initialY = e.touches[0].clientY - yOffset;
|
| 2189 |
+
} else {
|
| 2190 |
+
initialX = e.clientX - xOffset;
|
| 2191 |
+
initialY = e.clientY - yOffset;
|
| 2192 |
+
}
|
| 2193 |
+
|
| 2194 |
+
if (e.target.classList.contains('checklist-header') || e.target.parentElement.classList.contains('checklist-header')) {
|
| 2195 |
+
isDragging = true;
|
| 2196 |
+
checklist.style.transition = 'none';
|
| 2197 |
+
}
|
| 2198 |
+
};
|
| 2199 |
+
|
| 2200 |
+
window.dragChecklist = function(e) {
|
| 2201 |
+
if (isDragging) {
|
| 2202 |
+
e.preventDefault();
|
| 2203 |
+
|
| 2204 |
+
if (e.type === "touchmove") {
|
| 2205 |
+
currentX = e.touches[0].clientX - initialX;
|
| 2206 |
+
currentY = e.touches[0].clientY - initialY;
|
| 2207 |
+
} else {
|
| 2208 |
+
currentX = e.clientX - initialX;
|
| 2209 |
+
currentY = e.clientY - initialY;
|
| 2210 |
+
}
|
| 2211 |
+
|
| 2212 |
+
xOffset = currentX;
|
| 2213 |
+
yOffset = currentY;
|
| 2214 |
+
|
| 2215 |
+
const checklist = document.getElementById('demo-checklist');
|
| 2216 |
+
checklist.style.transform = `translate(${currentX}px, ${currentY}px)`;
|
| 2217 |
+
}
|
| 2218 |
+
};
|
| 2219 |
+
|
| 2220 |
+
window.endDrag = function(e) {
|
| 2221 |
+
initialX = currentX;
|
| 2222 |
+
initialY = currentY;
|
| 2223 |
+
isDragging = false;
|
| 2224 |
+
|
| 2225 |
+
const checklist = document.getElementById('demo-checklist');
|
| 2226 |
+
if (checklist) {
|
| 2227 |
+
checklist.style.transition = '';
|
| 2228 |
+
}
|
| 2229 |
+
};
|
| 2230 |
+
|
| 2231 |
+
// Add drag event listeners
|
| 2232 |
+
document.addEventListener('mousemove', dragChecklist);
|
| 2233 |
+
document.addEventListener('mouseup', endDrag);
|
| 2234 |
+
document.addEventListener('touchmove', dragChecklist);
|
| 2235 |
+
document.addEventListener('touchend', endDrag);
|
| 2236 |
+
}
|
| 2237 |
+
"""
|
components/ui_chat.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fixed streaming implementation that works with Gradio's expectations
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from typing import Generator, List, Dict, Any, Tuple
|
| 7 |
+
import os
|
| 8 |
+
import gradio as gr
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def process_chat_streaming(
|
| 12 |
+
user_message: str,
|
| 13 |
+
chat_history: List[Dict[str, str]],
|
| 14 |
+
mcp_client: Any,
|
| 15 |
+
current_emails: List[Dict[str, Any]],
|
| 16 |
+
pending_rules: List[Dict[str, Any]],
|
| 17 |
+
applied_rules: List[Dict[str, Any]],
|
| 18 |
+
rule_counter: int
|
| 19 |
+
) -> Generator[Tuple, None, None]:
|
| 20 |
+
"""Streaming chat handler that yields updates progressively"""
|
| 21 |
+
|
| 22 |
+
from .simple_agent import process_chat_message as process_agent_message
|
| 23 |
+
from .ui_utils import create_interactive_rule_cards
|
| 24 |
+
from .ui_tools import format_tool_message
|
| 25 |
+
|
| 26 |
+
if not user_message.strip():
|
| 27 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 28 |
+
"Ready...", pending_rules, rule_counter)
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
# Check API key
|
| 32 |
+
if not os.getenv('OPENROUTER_API_KEY'):
|
| 33 |
+
chat_history = chat_history + [{"role": "user", "content": user_message}]
|
| 34 |
+
error_msg = "⚠️ OpenRouter API key not configured. Please set OPENROUTER_API_KEY."
|
| 35 |
+
chat_history = chat_history + [{"role": "assistant", "content": error_msg}]
|
| 36 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 37 |
+
"API key missing", pending_rules, rule_counter)
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
# Add user message
|
| 42 |
+
chat_history = chat_history + [{"role": "user", "content": user_message}]
|
| 43 |
+
yield (chat_history, "", gr.update(),
|
| 44 |
+
"Processing...", pending_rules, rule_counter)
|
| 45 |
+
|
| 46 |
+
# Check if analyzing
|
| 47 |
+
analyze_keywords = ['analyze', 'suggest', 'organize', 'rules', 'inbox']
|
| 48 |
+
should_analyze = any(keyword in user_message.lower() for keyword in analyze_keywords)
|
| 49 |
+
|
| 50 |
+
if should_analyze and current_emails:
|
| 51 |
+
# Show analyzing tool message with "running" status
|
| 52 |
+
analysis_details = f"""Analyzing {len(current_emails)} emails...
|
| 53 |
+
|
| 54 |
+
Looking for patterns in:
|
| 55 |
+
• Sender domains and addresses
|
| 56 |
+
• Subject line keywords
|
| 57 |
+
• Email frequency by sender
|
| 58 |
+
• Common phrases and topics"""
|
| 59 |
+
|
| 60 |
+
tool_html = format_tool_message("analyzing_emails", analysis_details, "running")
|
| 61 |
+
chat_history = chat_history + [{"role": "assistant", "content": tool_html}]
|
| 62 |
+
|
| 63 |
+
yield (chat_history, "", gr.update(),
|
| 64 |
+
"Analyzing emails...", pending_rules, rule_counter)
|
| 65 |
+
|
| 66 |
+
# Small pause for effect
|
| 67 |
+
time.sleep(0.5)
|
| 68 |
+
|
| 69 |
+
# Get rule state
|
| 70 |
+
rule_state = {
|
| 71 |
+
'proposedRules': [r for r in pending_rules if r['status'] == 'pending'],
|
| 72 |
+
'activeRules': applied_rules,
|
| 73 |
+
'rejectedRules': [r for r in pending_rules if r['status'] == 'rejected']
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
# Use the unified agent for all messages
|
| 77 |
+
response_data = process_agent_message(
|
| 78 |
+
user_message=user_message,
|
| 79 |
+
emails=current_emails,
|
| 80 |
+
conversation_history=[m for m in chat_history[:-1]],
|
| 81 |
+
rule_state=rule_state
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
response_text = response_data.get('response', '')
|
| 85 |
+
extracted_rules = response_data.get('rules', [])
|
| 86 |
+
|
| 87 |
+
# If we were analyzing, update the tool message to show complete
|
| 88 |
+
if should_analyze and current_emails and len(chat_history) > 0:
|
| 89 |
+
# Create a new chat history list to ensure Gradio detects the change
|
| 90 |
+
new_chat_history = []
|
| 91 |
+
updated = False
|
| 92 |
+
|
| 93 |
+
for i, msg in enumerate(chat_history):
|
| 94 |
+
if not updated and msg.get('role') == 'assistant' and 'analyzing_emails' in msg.get('content', ''):
|
| 95 |
+
# Create updated message with complete details
|
| 96 |
+
complete_details = f"""Analyzed {len(current_emails)} emails successfully!
|
| 97 |
+
|
| 98 |
+
Found patterns:
|
| 99 |
+
• Newsletters: {len([e for e in current_emails if 'newsletter' in e.get('from_email', '').lower()])}
|
| 100 |
+
• Work emails: {len([e for e in current_emails if '@company.com' in e.get('from_email', '')])}
|
| 101 |
+
• Personal: {len([e for e in current_emails if not any(kw in e.get('from_email', '').lower() for kw in ['newsletter', 'noreply', 'promo'])])}
|
| 102 |
+
|
| 103 |
+
{len(extracted_rules)} patterns identified for rules."""
|
| 104 |
+
|
| 105 |
+
# Create new message object with updated content
|
| 106 |
+
updated_msg = msg.copy()
|
| 107 |
+
updated_msg['content'] = format_tool_message("analyzing_emails", complete_details, "complete")
|
| 108 |
+
new_chat_history.append(updated_msg)
|
| 109 |
+
updated = True
|
| 110 |
+
else:
|
| 111 |
+
new_chat_history.append(msg)
|
| 112 |
+
|
| 113 |
+
# Replace chat_history with the new list
|
| 114 |
+
chat_history = new_chat_history
|
| 115 |
+
|
| 116 |
+
yield (chat_history, "", gr.update(),
|
| 117 |
+
"Analysis complete", pending_rules, rule_counter)
|
| 118 |
+
time.sleep(0.3)
|
| 119 |
+
|
| 120 |
+
# Show rule creation tool message (brief) - now they're already created, so show as complete
|
| 121 |
+
if extracted_rules:
|
| 122 |
+
if should_analyze:
|
| 123 |
+
rules_details = f"Found {len(extracted_rules)} patterns in your emails"
|
| 124 |
+
else:
|
| 125 |
+
rules_details = f"Created {len(extracted_rules)} rule{'s' if len(extracted_rules) > 1 else ''} from your request"
|
| 126 |
+
|
| 127 |
+
# Show as complete since rules are already created at this point
|
| 128 |
+
tool_html = format_tool_message("creating_rules", rules_details, "complete")
|
| 129 |
+
chat_history = chat_history + [{"role": "assistant", "content": tool_html}]
|
| 130 |
+
|
| 131 |
+
yield (chat_history, "", gr.update(),
|
| 132 |
+
"Rules created", pending_rules, rule_counter)
|
| 133 |
+
|
| 134 |
+
time.sleep(0.3)
|
| 135 |
+
|
| 136 |
+
# Update rules
|
| 137 |
+
updated_pending_rules = pending_rules.copy()
|
| 138 |
+
for rule in extracted_rules:
|
| 139 |
+
if not any(r.get('rule_id') == rule.get('rule_id') for r in updated_pending_rules):
|
| 140 |
+
rule['status'] = 'pending'
|
| 141 |
+
# Don't create a duplicate 'id' field - use 'rule_id' consistently
|
| 142 |
+
updated_pending_rules.append(rule)
|
| 143 |
+
rule_counter += 1
|
| 144 |
+
|
| 145 |
+
# Clean response
|
| 146 |
+
clean_response = response_text
|
| 147 |
+
if "<!-- RULES_JSON_START" in clean_response:
|
| 148 |
+
clean_response = clean_response.split("<!-- RULES_JSON_START")[0].strip()
|
| 149 |
+
|
| 150 |
+
# Only do character streaming for non-analyzed messages
|
| 151 |
+
if not should_analyze:
|
| 152 |
+
streaming_msg = {"role": "assistant", "content": ""}
|
| 153 |
+
temp_history = chat_history + [streaming_msg]
|
| 154 |
+
|
| 155 |
+
# Stream in chunks
|
| 156 |
+
chunk_size = 5 # Characters per update
|
| 157 |
+
for i in range(0, len(clean_response), chunk_size):
|
| 158 |
+
streaming_msg["content"] = clean_response[:i+chunk_size]
|
| 159 |
+
# Use gr.update() if rules haven't changed, or update if they have
|
| 160 |
+
rule_cards_update = create_interactive_rule_cards(updated_pending_rules) if len(updated_pending_rules) != len(pending_rules) else gr.update()
|
| 161 |
+
yield (temp_history, "", rule_cards_update,
|
| 162 |
+
"Typing...", updated_pending_rules, rule_counter)
|
| 163 |
+
time.sleep(0.01) # Fast streaming
|
| 164 |
+
|
| 165 |
+
# Final message
|
| 166 |
+
chat_history = chat_history + [{"role": "assistant", "content": clean_response}]
|
| 167 |
+
|
| 168 |
+
status = f"✅ Found {len(extracted_rules)} rules" if extracted_rules else "Complete"
|
| 169 |
+
yield (chat_history, "", create_interactive_rule_cards(updated_pending_rules),
|
| 170 |
+
status, updated_pending_rules, rule_counter)
|
| 171 |
+
|
| 172 |
+
except Exception as e:
|
| 173 |
+
error_msg = f"Error: {str(e)}"
|
| 174 |
+
chat_history = chat_history + [{"role": "assistant", "content": error_msg}]
|
| 175 |
+
yield (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 176 |
+
error_msg, pending_rules, rule_counter)
|
components/ui_tools.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Enhanced chat handling with tool visibility and proper streaming
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
import json
|
| 7 |
+
from typing import List, Dict, Any, Tuple
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def format_tool_message(tool_name: str, details: str = "", status: str = "running") -> str:
|
| 13 |
+
"""Format a tool call as HTML content"""
|
| 14 |
+
icons = {
|
| 15 |
+
"analyzing_emails": "📊",
|
| 16 |
+
"extracting_patterns": "🔍",
|
| 17 |
+
"creating_rules": "📋",
|
| 18 |
+
"thinking": "🤔"
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
# Create a styled div that looks like a tool call
|
| 22 |
+
tool_content = f"""
|
| 23 |
+
<div style="background: #e3f2fd; border: 1px solid #1976d2; border-radius: 8px; padding: 12px; margin: 8px 0;">
|
| 24 |
+
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
| 25 |
+
<span style="font-size: 20px;">{icons.get(tool_name, '🔧')}</span>
|
| 26 |
+
<span style="font-weight: 600; color: #1976d2;">{tool_name.replace('_', ' ').title()}</span>
|
| 27 |
+
<span style="margin-left: auto; color: #666; font-size: 12px;">
|
| 28 |
+
{'⏳ Running...' if status == 'running' else '✓ Complete'}
|
| 29 |
+
</span>
|
| 30 |
+
</div>
|
| 31 |
+
<div style="background: white; border-radius: 4px; padding: 8px; font-family: monospace; font-size: 12px; white-space: pre-wrap;">
|
| 32 |
+
{details or 'Processing...'}
|
| 33 |
+
</div>
|
| 34 |
+
</div>"""
|
| 35 |
+
|
| 36 |
+
return tool_content
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def process_chat_with_tools(
|
| 40 |
+
user_message: str,
|
| 41 |
+
chat_history: List[Dict[str, str]],
|
| 42 |
+
mcp_client: Any,
|
| 43 |
+
current_emails: List[Dict[str, Any]],
|
| 44 |
+
pending_rules: List[Dict[str, Any]],
|
| 45 |
+
applied_rules: List[Dict[str, Any]],
|
| 46 |
+
rule_counter: int
|
| 47 |
+
) -> Tuple:
|
| 48 |
+
"""Process chat messages with tool visibility"""
|
| 49 |
+
|
| 50 |
+
from .simple_agent import process_chat_message as process_agent_message
|
| 51 |
+
from .ui_utils import create_interactive_rule_cards
|
| 52 |
+
|
| 53 |
+
if not user_message.strip():
|
| 54 |
+
return (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 55 |
+
"Ready...", pending_rules, rule_counter)
|
| 56 |
+
|
| 57 |
+
# Check if API key is available
|
| 58 |
+
if not os.getenv('OPENROUTER_API_KEY'):
|
| 59 |
+
chat_history.append({"role": "user", "content": user_message})
|
| 60 |
+
error_msg = "⚠️ OpenRouter API key not configured. Please set OPENROUTER_API_KEY in HuggingFace Spaces secrets to enable AI features."
|
| 61 |
+
chat_history.append({"role": "assistant", "content": error_msg})
|
| 62 |
+
return (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 63 |
+
"API key missing", pending_rules, rule_counter)
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
# Add user message
|
| 67 |
+
chat_history.append({"role": "user", "content": user_message})
|
| 68 |
+
|
| 69 |
+
# Check if we should analyze emails
|
| 70 |
+
analyze_keywords = ['analyze', 'suggest', 'organize', 'rules', 'help me organize',
|
| 71 |
+
'create rules', 'inbox', 'patterns']
|
| 72 |
+
should_analyze = any(keyword in user_message.lower() for keyword in analyze_keywords)
|
| 73 |
+
|
| 74 |
+
if should_analyze and current_emails:
|
| 75 |
+
# Add tool message for email analysis
|
| 76 |
+
analysis_details = f"""Analyzing {len(current_emails)} emails...
|
| 77 |
+
|
| 78 |
+
Looking for patterns in:
|
| 79 |
+
• Sender domains and addresses
|
| 80 |
+
• Subject line keywords
|
| 81 |
+
• Email frequency by sender
|
| 82 |
+
• Common phrases and topics
|
| 83 |
+
• Time patterns
|
| 84 |
+
|
| 85 |
+
Email categories found:
|
| 86 |
+
• Newsletters: {len([e for e in current_emails if 'newsletter' in e.get('from_email', '').lower()])}
|
| 87 |
+
• Work emails: {len([e for e in current_emails if any(domain in e.get('from_email', '') for domain in ['company.com', 'work.com'])])}
|
| 88 |
+
• Personal: {len([e for e in current_emails if not any(keyword in e.get('from_email', '').lower() for keyword in ['newsletter', 'promo', 'noreply'])])}
|
| 89 |
+
"""
|
| 90 |
+
# Create a combined message with tool output
|
| 91 |
+
tool_html = format_tool_message("analyzing_emails", analysis_details, "complete")
|
| 92 |
+
# Add as assistant message with special formatting
|
| 93 |
+
chat_history.append({
|
| 94 |
+
"role": "assistant",
|
| 95 |
+
"content": tool_html
|
| 96 |
+
})
|
| 97 |
+
|
| 98 |
+
# Get current rule state
|
| 99 |
+
rule_state = {
|
| 100 |
+
'proposedRules': [r for r in pending_rules if r['status'] == 'pending'],
|
| 101 |
+
'activeRules': applied_rules,
|
| 102 |
+
'rejectedRules': [r for r in pending_rules if r['status'] == 'rejected']
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
# Call the agent
|
| 106 |
+
response_data = process_agent_message(
|
| 107 |
+
user_message=user_message,
|
| 108 |
+
emails=current_emails,
|
| 109 |
+
conversation_history=chat_history[:-2] if should_analyze else chat_history[:-1], # Exclude tool message
|
| 110 |
+
rule_state=rule_state
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Extract response and rules
|
| 114 |
+
response_text = response_data.get('response', '')
|
| 115 |
+
extracted_rules = response_data.get('rules', [])
|
| 116 |
+
|
| 117 |
+
# If rules were extracted, add a tool message
|
| 118 |
+
if extracted_rules:
|
| 119 |
+
rules_details = f"""Created {len(extracted_rules)} rules:
|
| 120 |
+
|
| 121 |
+
"""
|
| 122 |
+
for rule in extracted_rules:
|
| 123 |
+
rules_details += f"📋 {rule['name']}\n"
|
| 124 |
+
rules_details += f" {rule['description']}\n"
|
| 125 |
+
rules_details += f" Confidence: {rule.get('confidence', 0.8)*100:.0f}%\n\n"
|
| 126 |
+
|
| 127 |
+
tool_html = format_tool_message("creating_rules", rules_details, "complete")
|
| 128 |
+
|
| 129 |
+
# Update rules
|
| 130 |
+
updated_pending_rules = pending_rules.copy()
|
| 131 |
+
for rule in extracted_rules:
|
| 132 |
+
if not any(r.get('rule_id') == rule.get('rule_id') for r in updated_pending_rules):
|
| 133 |
+
rule['status'] = 'pending'
|
| 134 |
+
rule['id'] = rule.get('rule_id', f'rule_{rule_counter}')
|
| 135 |
+
updated_pending_rules.append(rule)
|
| 136 |
+
rule_counter += 1
|
| 137 |
+
|
| 138 |
+
# Add assistant response (clean it of hidden JSON)
|
| 139 |
+
clean_response = response_text
|
| 140 |
+
if "<!-- RULES_JSON_START" in clean_response:
|
| 141 |
+
clean_response = clean_response.split("<!-- RULES_JSON_START")[0].strip()
|
| 142 |
+
|
| 143 |
+
# If we have tool output, prepend it to the response
|
| 144 |
+
if extracted_rules and 'tool_html' in locals():
|
| 145 |
+
combined_content = tool_html + "\n\n" + clean_response
|
| 146 |
+
chat_history.append({"role": "assistant", "content": combined_content})
|
| 147 |
+
else:
|
| 148 |
+
chat_history.append({"role": "assistant", "content": clean_response})
|
| 149 |
+
|
| 150 |
+
# Status message
|
| 151 |
+
if extracted_rules:
|
| 152 |
+
status = f"✅ Found {len(extracted_rules)} new rules"
|
| 153 |
+
else:
|
| 154 |
+
status = "Processing complete"
|
| 155 |
+
|
| 156 |
+
return (chat_history, "", create_interactive_rule_cards(updated_pending_rules),
|
| 157 |
+
status, updated_pending_rules, rule_counter)
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
error_msg = "I encountered an error: " + str(e) + ". Please try again."
|
| 161 |
+
chat_history.append({"role": "assistant", "content": error_msg})
|
| 162 |
+
return (chat_history, "", create_interactive_rule_cards(pending_rules),
|
| 163 |
+
f"Error: {str(e)}", pending_rules, rule_counter)
|
| 164 |
+
|
| 165 |
+
|
components/ui_utils.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
UI utility functions - merged from display.py and helpers.py
|
| 3 |
+
Making the UI component self-contained
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from typing import List, Dict, Any, Tuple
|
| 11 |
+
import bleach
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def sanitize_html(text: str, allow_tags: bool = False) -> str:
|
| 15 |
+
"""Sanitize HTML content to prevent XSS attacks"""
|
| 16 |
+
if not text:
|
| 17 |
+
return ""
|
| 18 |
+
|
| 19 |
+
if allow_tags:
|
| 20 |
+
# Allow basic formatting tags for email bodies
|
| 21 |
+
allowed_tags = ['p', 'br', 'b', 'i', 'u', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code']
|
| 22 |
+
allowed_attrs = {'a': ['href', 'title']}
|
| 23 |
+
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
| 24 |
+
else:
|
| 25 |
+
# For fields like subject, sender name - no HTML allowed
|
| 26 |
+
return bleach.clean(text, tags=[], attributes={}, strip=True)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_folder_counts(emails: List[Dict]) -> List[Tuple[str, int]]:
|
| 32 |
+
"""Extract unique folders and their counts from email data"""
|
| 33 |
+
folder_counts = defaultdict(int)
|
| 34 |
+
|
| 35 |
+
for email in emails:
|
| 36 |
+
if 'folder' in email and email['folder']:
|
| 37 |
+
folder_counts[email['folder']] += 1
|
| 38 |
+
else:
|
| 39 |
+
folder_counts['Inbox'] += 1
|
| 40 |
+
|
| 41 |
+
return sorted(folder_counts.items())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_folder_dropdown_choices(emails: List[Dict]) -> List[str]:
|
| 45 |
+
"""Get folder choices for dropdown in format 'Folder Name (Count)'"""
|
| 46 |
+
folder_counts = get_folder_counts(emails)
|
| 47 |
+
return [f"{folder_name} ({count})" for folder_name, count in folder_counts]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def extract_folder_name(folder_choice: str) -> str:
|
| 51 |
+
"""Extract folder name from dropdown choice format 'Folder Name (Count)'"""
|
| 52 |
+
if '(' in folder_choice:
|
| 53 |
+
return folder_choice.split(' (')[0]
|
| 54 |
+
return folder_choice
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def format_date(date_string: str) -> str:
|
| 58 |
+
"""Format ISO date string to readable format"""
|
| 59 |
+
try:
|
| 60 |
+
dt = datetime.fromisoformat(date_string.replace('Z', '+00:00'))
|
| 61 |
+
now = datetime.now()
|
| 62 |
+
|
| 63 |
+
if dt.date() == now.date():
|
| 64 |
+
return dt.strftime("%H:%M")
|
| 65 |
+
elif dt.year == now.year:
|
| 66 |
+
return dt.strftime("%b %d")
|
| 67 |
+
else:
|
| 68 |
+
return dt.strftime("%b %d, %Y")
|
| 69 |
+
except (ValueError, AttributeError) as e:
|
| 70 |
+
return date_string
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def sort_emails(emails: List[Dict], sort_option: str) -> List[Dict]:
|
| 74 |
+
"""Sort emails based on the selected sort option"""
|
| 75 |
+
if not emails:
|
| 76 |
+
return emails
|
| 77 |
+
|
| 78 |
+
def get_email_date(email):
|
| 79 |
+
try:
|
| 80 |
+
date_str = email.get('date', '')
|
| 81 |
+
if date_str:
|
| 82 |
+
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
| 83 |
+
else:
|
| 84 |
+
return datetime(1970, 1, 1)
|
| 85 |
+
except (ValueError, AttributeError, KeyError) as e:
|
| 86 |
+
return datetime(1970, 1, 1)
|
| 87 |
+
|
| 88 |
+
if sort_option == "Newest First":
|
| 89 |
+
return sorted(emails, key=get_email_date, reverse=True)
|
| 90 |
+
elif sort_option == "Oldest First":
|
| 91 |
+
return sorted(emails, key=get_email_date, reverse=False)
|
| 92 |
+
else:
|
| 93 |
+
return emails
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def create_email_html(emails: List[Dict], selected_folder: str = "Inbox", expanded_email_id: str = None, compact: bool = True) -> str:
|
| 97 |
+
"""Create HTML for email list"""
|
| 98 |
+
folder_counts = dict(get_folder_counts(emails))
|
| 99 |
+
clean_folder_name = extract_folder_name(selected_folder)
|
| 100 |
+
|
| 101 |
+
html = """
|
| 102 |
+
<style>
|
| 103 |
+
.email-container {
|
| 104 |
+
font-family: var(--font-family);
|
| 105 |
+
font-size: var(--font-size-sm);
|
| 106 |
+
}
|
| 107 |
+
.email-list {
|
| 108 |
+
border: var(--border-width) solid var(--color-gray-light);
|
| 109 |
+
border-radius: var(--border-radius);
|
| 110 |
+
overflow: hidden;
|
| 111 |
+
background: var(--color-white);
|
| 112 |
+
}
|
| 113 |
+
.email-row {
|
| 114 |
+
padding: var(--spacing-2) var(--spacing-3);
|
| 115 |
+
border-bottom: var(--border-width) solid var(--color-gray-light);
|
| 116 |
+
cursor: pointer;
|
| 117 |
+
transition: background-color var(--transition-fast);
|
| 118 |
+
}
|
| 119 |
+
.email-row:hover {
|
| 120 |
+
background-color: var(--color-gray-lighter);
|
| 121 |
+
}
|
| 122 |
+
.email-row:last-child {
|
| 123 |
+
border-bottom: none;
|
| 124 |
+
}
|
| 125 |
+
.email-row-expanded {
|
| 126 |
+
background-color: var(--color-gray-lighter);
|
| 127 |
+
padding: var(--spacing-3);
|
| 128 |
+
}
|
| 129 |
+
.email-content {
|
| 130 |
+
display: flex;
|
| 131 |
+
align-items: center;
|
| 132 |
+
gap: var(--spacing-2);
|
| 133 |
+
}
|
| 134 |
+
.email-sender {
|
| 135 |
+
font-weight: var(--font-weight-medium);
|
| 136 |
+
color: var(--color-dark);
|
| 137 |
+
min-width: 120px;
|
| 138 |
+
max-width: 120px;
|
| 139 |
+
overflow: hidden;
|
| 140 |
+
text-overflow: ellipsis;
|
| 141 |
+
white-space: nowrap;
|
| 142 |
+
}
|
| 143 |
+
.email-sender-unread {
|
| 144 |
+
font-weight: var(--font-weight-semibold);
|
| 145 |
+
}
|
| 146 |
+
.email-subject {
|
| 147 |
+
color: var(--color-dark);
|
| 148 |
+
font-weight: var(--font-weight-medium);
|
| 149 |
+
}
|
| 150 |
+
.email-snippet {
|
| 151 |
+
color: var(--color-gray);
|
| 152 |
+
}
|
| 153 |
+
.email-meta {
|
| 154 |
+
color: var(--color-gray);
|
| 155 |
+
flex: 1;
|
| 156 |
+
overflow: hidden;
|
| 157 |
+
text-overflow: ellipsis;
|
| 158 |
+
white-space: nowrap;
|
| 159 |
+
}
|
| 160 |
+
.email-date {
|
| 161 |
+
color: var(--color-gray);
|
| 162 |
+
font-size: var(--font-size-xs);
|
| 163 |
+
min-width: 50px;
|
| 164 |
+
text-align: right;
|
| 165 |
+
}
|
| 166 |
+
.email-label {
|
| 167 |
+
padding: 2px 6px;
|
| 168 |
+
border-radius: 12px;
|
| 169 |
+
font-size: var(--font-size-xs);
|
| 170 |
+
font-weight: var(--font-weight-medium);
|
| 171 |
+
margin-left: var(--spacing-1);
|
| 172 |
+
}
|
| 173 |
+
.label-unread {
|
| 174 |
+
background: var(--color-primary);
|
| 175 |
+
color: var(--color-white);
|
| 176 |
+
}
|
| 177 |
+
.label-important {
|
| 178 |
+
background: var(--color-danger);
|
| 179 |
+
color: var(--color-white);
|
| 180 |
+
}
|
| 181 |
+
.label-starred {
|
| 182 |
+
background: var(--color-warning);
|
| 183 |
+
color: var(--color-white);
|
| 184 |
+
}
|
| 185 |
+
.email-body {
|
| 186 |
+
margin-top: var(--spacing-3);
|
| 187 |
+
padding: var(--spacing-3);
|
| 188 |
+
background: var(--color-white);
|
| 189 |
+
border-radius: var(--border-radius-sm);
|
| 190 |
+
border-left: 3px solid var(--color-primary);
|
| 191 |
+
line-height: 1.5;
|
| 192 |
+
}
|
| 193 |
+
</style>
|
| 194 |
+
<div class="email-container">
|
| 195 |
+
<div class="email-list">
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
for i, email in enumerate(emails):
|
| 199 |
+
# Email metadata with sanitization
|
| 200 |
+
email_id = email.get('id', f'email_{i}')
|
| 201 |
+
sender_name = sanitize_html(email.get('from_name', email.get('from_email', 'Unknown'))[:20])
|
| 202 |
+
subject = sanitize_html(email.get('subject', 'No Subject'))
|
| 203 |
+
snippet = sanitize_html(email.get('snippet', ''))
|
| 204 |
+
formatted_date = format_date(email.get('date', ''))
|
| 205 |
+
is_unread = "UNREAD" in email.get("labelIds", [])
|
| 206 |
+
is_expanded = expanded_email_id == email.get('id')
|
| 207 |
+
|
| 208 |
+
if compact and not is_expanded:
|
| 209 |
+
# Single line compact format
|
| 210 |
+
subject_preview = subject[:40] + "..." if len(subject) > 40 else subject
|
| 211 |
+
snippet_preview = snippet[:30] + "..." if len(snippet) > 30 else snippet
|
| 212 |
+
|
| 213 |
+
# Check for draft indicator
|
| 214 |
+
has_draft = "DRAFT_PENDING" in email.get("labelIds", []) or "DRAFT_PREVIEW" in email.get("labelIds", [])
|
| 215 |
+
draft_indicator = '<span class="draft-indicator" title="Draft reply available">📝</span> ' if has_draft else ""
|
| 216 |
+
|
| 217 |
+
# Update labels_html to use CSS classes
|
| 218 |
+
labels_html = ""
|
| 219 |
+
if email.get("labelIds"):
|
| 220 |
+
important_labels = ["UNREAD", "IMPORTANT", "STARRED"]
|
| 221 |
+
shown_labels = [l for l in email.get("labelIds", []) if l in important_labels][:2]
|
| 222 |
+
for label in shown_labels:
|
| 223 |
+
label_short = {"UNREAD": "New", "IMPORTANT": "!", "STARRED": "★"}.get(label, label[:3])
|
| 224 |
+
label_class = {"UNREAD": "label-unread", "IMPORTANT": "label-important", "STARRED": "label-starred"}.get(label, "")
|
| 225 |
+
labels_html += f'<span class="email-label {label_class}">{label_short}</span>'
|
| 226 |
+
|
| 227 |
+
sender_class = "email-sender email-sender-unread" if is_unread else "email-sender"
|
| 228 |
+
|
| 229 |
+
html += f"""
|
| 230 |
+
<div class="email-row" onclick="handleEmailAction('{email_id}')">
|
| 231 |
+
<div class="email-content">
|
| 232 |
+
<span class="{sender_class}">{sender_name}</span>
|
| 233 |
+
<span>•</span>
|
| 234 |
+
<span class="email-meta">
|
| 235 |
+
{draft_indicator}<span class="email-subject">{subject_preview}</span> - <span class="email-snippet">{snippet_preview}</span>
|
| 236 |
+
</span>
|
| 237 |
+
{labels_html}
|
| 238 |
+
<span class="email-date">{formatted_date}</span>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
"""
|
| 242 |
+
else:
|
| 243 |
+
# Expanded view
|
| 244 |
+
# Update labels_html to use CSS classes
|
| 245 |
+
labels_html = ""
|
| 246 |
+
if email.get("labelIds"):
|
| 247 |
+
important_labels = ["UNREAD", "IMPORTANT", "STARRED"]
|
| 248 |
+
shown_labels = [l for l in email.get("labelIds", []) if l in important_labels][:2]
|
| 249 |
+
for label in shown_labels:
|
| 250 |
+
label_short = {"UNREAD": "New", "IMPORTANT": "!", "STARRED": "★"}.get(label, label[:3])
|
| 251 |
+
label_class = {"UNREAD": "label-unread", "IMPORTANT": "label-important", "STARRED": "label-starred"}.get(label, "")
|
| 252 |
+
labels_html += f'<span class="email-label {label_class}">{label_short}</span>'
|
| 253 |
+
|
| 254 |
+
# Sanitize expanded view data
|
| 255 |
+
from_name_full = sanitize_html(email.get('from_name', sender_name))
|
| 256 |
+
|
| 257 |
+
html += f"""
|
| 258 |
+
<div class="email-row email-row-expanded" onclick="handleEmailAction('{email_id}')">
|
| 259 |
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--spacing-2);">
|
| 260 |
+
<span style="font-weight: var(--font-weight-semibold); color: var(--color-dark);">{from_name_full}</span>
|
| 261 |
+
<div style="display: flex; align-items: center; gap: var(--spacing-2);">
|
| 262 |
+
{labels_html}
|
| 263 |
+
<span class="email-date">{formatted_date}</span>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
<div style="font-weight: var(--font-weight-medium); color: var(--color-dark); margin-bottom: var(--spacing-2);">{subject}</div>
|
| 267 |
+
"""
|
| 268 |
+
|
| 269 |
+
if 'body' in email:
|
| 270 |
+
# Sanitize body content with allowed tags
|
| 271 |
+
safe_body = sanitize_html(email['body'], allow_tags=True)
|
| 272 |
+
html += f"""
|
| 273 |
+
<div class="email-body">
|
| 274 |
+
<div style="color: var(--color-dark); white-space: pre-line;">{safe_body}</div>
|
| 275 |
+
</div>
|
| 276 |
+
"""
|
| 277 |
+
|
| 278 |
+
# Check for drafts to display
|
| 279 |
+
if "DRAFT_PENDING" in email.get("labelIds", []) or "DRAFT_PREVIEW" in email.get("labelIds", []):
|
| 280 |
+
# For now, show a simple draft preview
|
| 281 |
+
# In production, we'd fetch actual drafts from backend
|
| 282 |
+
html += f"""
|
| 283 |
+
<div class="draft-preview" style="margin-top: var(--spacing-4); padding: var(--spacing-3); background-color: #e3f2fd; border-radius: var(--radius-default); border-left: 4px solid #2196f3;">
|
| 284 |
+
<h4 style="margin: 0 0 var(--spacing-2) 0; color: #1976d2; font-size: var(--font-size-sm); display: flex; align-items: center;">
|
| 285 |
+
<span style="margin-right: var(--spacing-1);">📝</span> Draft Reply Ready
|
| 286 |
+
</h4>
|
| 287 |
+
<div style="font-size: var(--font-size-sm); color: var(--color-dark); font-style: italic;">
|
| 288 |
+
A personalized draft reply has been prepared for this email.
|
| 289 |
+
Click to view and edit the full draft.
|
| 290 |
+
</div>
|
| 291 |
+
<div style="margin-top: var(--spacing-2);">
|
| 292 |
+
<button style="font-size: var(--font-size-xs); padding: 4px 12px; background: #2196f3; color: white; border: none; border-radius: var(--radius-small); cursor: pointer;">
|
| 293 |
+
View Draft
|
| 294 |
+
</button>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
"""
|
| 298 |
+
|
| 299 |
+
html += """
|
| 300 |
+
</div>
|
| 301 |
+
"""
|
| 302 |
+
|
| 303 |
+
html += """
|
| 304 |
+
</div>
|
| 305 |
+
</div>"""
|
| 306 |
+
return html
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def filter_emails(folder: str, search_query: str = "", sort_option: str = "Newest First", emails_source: List[Dict] = None) -> List[Dict]:
|
| 310 |
+
"""Filter and sort emails based on folder, search query, and sort option"""
|
| 311 |
+
if emails_source is None:
|
| 312 |
+
return []
|
| 313 |
+
|
| 314 |
+
filtered = emails_source
|
| 315 |
+
clean_folder_name = extract_folder_name(folder)
|
| 316 |
+
|
| 317 |
+
# Filter by folder
|
| 318 |
+
folder_filtered = []
|
| 319 |
+
for email in filtered:
|
| 320 |
+
if 'folder' in email and email['folder'] == clean_folder_name:
|
| 321 |
+
folder_filtered.append(email)
|
| 322 |
+
elif not 'folder' in email and 'INBOX' == clean_folder_name:
|
| 323 |
+
folder_filtered.append(email)
|
| 324 |
+
|
| 325 |
+
filtered = folder_filtered
|
| 326 |
+
|
| 327 |
+
# Filter by search query
|
| 328 |
+
if search_query:
|
| 329 |
+
filtered = [email for email in filtered if
|
| 330 |
+
search_query.lower() in email.get('from_name', '').lower() or
|
| 331 |
+
search_query.lower() in email.get('from_email', '').lower() or
|
| 332 |
+
search_query.lower() in email.get('subject', '').lower() or
|
| 333 |
+
search_query.lower() in email.get('snippet', '').lower()]
|
| 334 |
+
|
| 335 |
+
# Sort emails
|
| 336 |
+
filtered = sort_emails(filtered, sort_option)
|
| 337 |
+
|
| 338 |
+
return filtered
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def create_preview_banner(preview_rules_count: int = 0) -> str:
|
| 342 |
+
"""Create the preview mode banner HTML"""
|
| 343 |
+
if preview_rules_count == 0:
|
| 344 |
+
return ""
|
| 345 |
+
|
| 346 |
+
return f"""
|
| 347 |
+
<style>
|
| 348 |
+
#preview-banner {{
|
| 349 |
+
background: var(--color-warning);
|
| 350 |
+
color: var(--color-white);
|
| 351 |
+
padding: var(--spacing-2) var(--spacing-3);
|
| 352 |
+
margin-bottom: var(--spacing-2);
|
| 353 |
+
border-radius: var(--border-radius-sm);
|
| 354 |
+
display: flex;
|
| 355 |
+
justify-content: space-between;
|
| 356 |
+
align-items: center;
|
| 357 |
+
font-size: var(--font-size-sm);
|
| 358 |
+
font-weight: var(--font-weight-medium);
|
| 359 |
+
box-shadow: var(--shadow-sm);
|
| 360 |
+
}}
|
| 361 |
+
#exit-preview-btn {{
|
| 362 |
+
background: rgba(255, 255, 255, 0.2);
|
| 363 |
+
border: 1px solid rgba(255, 255, 255, 0.4);
|
| 364 |
+
color: var(--color-white);
|
| 365 |
+
padding: var(--spacing-1) var(--spacing-2);
|
| 366 |
+
border-radius: var(--border-radius-sm);
|
| 367 |
+
cursor: pointer;
|
| 368 |
+
font-size: var(--font-size-xs);
|
| 369 |
+
font-weight: var(--font-weight-medium);
|
| 370 |
+
transition: background var(--transition-fast);
|
| 371 |
+
}}
|
| 372 |
+
#exit-preview-btn:hover {{
|
| 373 |
+
background: rgba(255, 255, 255, 0.3);
|
| 374 |
+
}}
|
| 375 |
+
</style>
|
| 376 |
+
<div id="preview-banner">
|
| 377 |
+
<div style="display: flex; align-items: center; gap: var(--spacing-2);">
|
| 378 |
+
<span>👁️</span>
|
| 379 |
+
<span>Preview Mode ({preview_rules_count} rule{'s' if preview_rules_count != 1 else ''})</span>
|
| 380 |
+
</div>
|
| 381 |
+
<button id="exit-preview-btn">
|
| 382 |
+
Exit Preview
|
| 383 |
+
</button>
|
| 384 |
+
</div>
|
| 385 |
+
"""
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def create_sleek_rule_card(rule: Dict) -> str:
|
| 389 |
+
"""Create a single sleek two-row rule card with expandable details"""
|
| 390 |
+
rule_id = rule.get('rule_id', rule.get('id', ''))
|
| 391 |
+
print(f"DEBUG ui_utils: Creating card for rule '{rule.get('name')}' with ID: '{rule_id}'")
|
| 392 |
+
rule_name = sanitize_html(rule.get('name', 'Unnamed Rule'))
|
| 393 |
+
status = rule.get('status', 'pending')
|
| 394 |
+
|
| 395 |
+
# Determine button set based on status
|
| 396 |
+
buttons_html = ""
|
| 397 |
+
if status == 'pending':
|
| 398 |
+
buttons_html = f"""
|
| 399 |
+
<button class="rule-btn preview-btn" data-rule-id="{rule_id}">Show affected</button>
|
| 400 |
+
<button class="rule-btn archive-btn" data-rule-id="{rule_id}">Archive</button>
|
| 401 |
+
"""
|
| 402 |
+
elif status == 'preview':
|
| 403 |
+
buttons_html = f"""
|
| 404 |
+
<button class="rule-btn accept-btn" data-rule-id="{rule_id}">✓ Accept</button>
|
| 405 |
+
<button class="rule-btn reject-btn" data-rule-id="{rule_id}">✗ Reject</button>
|
| 406 |
+
"""
|
| 407 |
+
elif status == 'accepted':
|
| 408 |
+
buttons_html = f"""
|
| 409 |
+
<button class="rule-btn run-btn" data-rule-id="{rule_id}">▶ Run Rule</button>
|
| 410 |
+
<button class="rule-btn archive-btn" data-rule-id="{rule_id}">Archive</button>
|
| 411 |
+
"""
|
| 412 |
+
|
| 413 |
+
card_class = f"rule-card-v2 rule-status-{status}"
|
| 414 |
+
|
| 415 |
+
# Build details HTML
|
| 416 |
+
details_html = ""
|
| 417 |
+
|
| 418 |
+
# Add conditions
|
| 419 |
+
conditions = rule.get('conditions', [])
|
| 420 |
+
if conditions:
|
| 421 |
+
details_html += "<div style='margin-top: 12px;'><strong>Conditions:</strong><ul style='margin: 4px 0; padding-left: 20px;'>"
|
| 422 |
+
for condition in conditions:
|
| 423 |
+
field = condition.get('field', '')
|
| 424 |
+
operator = condition.get('operator', '')
|
| 425 |
+
value = condition.get('value', '')
|
| 426 |
+
# Convert operator to readable format
|
| 427 |
+
op_map = {'contains': 'contains', 'equals': 'equals', 'startswith': 'starts with', 'endswith': 'ends with'}
|
| 428 |
+
readable_op = op_map.get(operator, operator)
|
| 429 |
+
details_html += f"<li>{sanitize_html(field)} {readable_op} \"{sanitize_html(str(value))}\"</li>"
|
| 430 |
+
details_html += "</ul></div>"
|
| 431 |
+
|
| 432 |
+
# Add actions
|
| 433 |
+
actions = rule.get('actions', [])
|
| 434 |
+
if actions:
|
| 435 |
+
details_html += "<div style='margin-top: 8px;'><strong>Actions:</strong><ul style='margin: 4px 0; padding-left: 20px;'>"
|
| 436 |
+
for action in actions:
|
| 437 |
+
action_type = action.get('type', '')
|
| 438 |
+
if action_type == 'move':
|
| 439 |
+
folder = action.get('parameters', {}).get('folder', 'Unknown')
|
| 440 |
+
details_html += f"<li>Move to {sanitize_html(folder)} folder</li>"
|
| 441 |
+
elif action_type == 'label':
|
| 442 |
+
label = action.get('parameters', {}).get('label', 'Unknown')
|
| 443 |
+
details_html += f"<li>Add label: {sanitize_html(label)}</li>"
|
| 444 |
+
elif action_type == 'mark_as_read':
|
| 445 |
+
details_html += "<li>Mark as read</li>"
|
| 446 |
+
elif action_type == 'draft_reply':
|
| 447 |
+
details_html += "<li>Create draft reply</li>"
|
| 448 |
+
else:
|
| 449 |
+
details_html += f"<li>{sanitize_html(action_type)}</li>"
|
| 450 |
+
details_html += "</ul></div>"
|
| 451 |
+
|
| 452 |
+
# Add confidence if available
|
| 453 |
+
confidence = rule.get('confidence', 0)
|
| 454 |
+
if confidence > 0:
|
| 455 |
+
details_html += f"<div style='margin-top: 8px;'><strong>Confidence:</strong> {int(confidence * 100)}%</div>"
|
| 456 |
+
|
| 457 |
+
return f"""
|
| 458 |
+
<div id="rule-{rule_id}" class="{card_class}" onclick="toggleRuleDetails('{rule_id}')">
|
| 459 |
+
<div class="rule-row-1">
|
| 460 |
+
<span class="rule-expand-arrow" id="arrow-{rule_id}">▶</span>
|
| 461 |
+
<span class="rule-name">{rule_name}</span>
|
| 462 |
+
</div>
|
| 463 |
+
<div class="rule-row-2">
|
| 464 |
+
<div class="rule-actions">{buttons_html}</div>
|
| 465 |
+
</div>
|
| 466 |
+
<div class="rule-details" id="details-{rule_id}" style="display: none;">
|
| 467 |
+
{details_html}
|
| 468 |
+
</div>
|
| 469 |
+
</div>
|
| 470 |
+
"""
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def create_interactive_rule_cards(pending_rules: List[Dict], compact: bool = True) -> str:
|
| 474 |
+
"""Create HTML for interactive rule cards display"""
|
| 475 |
+
if not pending_rules:
|
| 476 |
+
return """
|
| 477 |
+
<style>
|
| 478 |
+
.empty-rules {
|
| 479 |
+
font-family: var(--font-family);
|
| 480 |
+
padding: var(--spacing-4);
|
| 481 |
+
text-align: center;
|
| 482 |
+
color: var(--color-gray);
|
| 483 |
+
background: var(--color-gray-lighter);
|
| 484 |
+
border-radius: var(--border-radius);
|
| 485 |
+
margin: var(--spacing-2);
|
| 486 |
+
}
|
| 487 |
+
.empty-rules-icon {
|
| 488 |
+
font-size: 2rem;
|
| 489 |
+
margin-bottom: var(--spacing-2);
|
| 490 |
+
}
|
| 491 |
+
.empty-rules h4 {
|
| 492 |
+
margin: var(--spacing-2) 0;
|
| 493 |
+
color: var(--color-dark);
|
| 494 |
+
font-size: var(--font-size-base);
|
| 495 |
+
font-weight: var(--font-weight-semibold);
|
| 496 |
+
}
|
| 497 |
+
.empty-rules p {
|
| 498 |
+
margin: 0;
|
| 499 |
+
font-size: var(--font-size-sm);
|
| 500 |
+
}
|
| 501 |
+
</style>
|
| 502 |
+
<div class="empty-rules">
|
| 503 |
+
<div class="empty-rules-icon">📋</div>
|
| 504 |
+
<h4>No Rules Yet</h4>
|
| 505 |
+
<p>Click "Analyze" to get started!</p>
|
| 506 |
+
</div>
|
| 507 |
+
"""
|
| 508 |
+
|
| 509 |
+
# Separate active and archived rules
|
| 510 |
+
active_rules = [r for r in pending_rules if r.get("status") != "rejected"]
|
| 511 |
+
archived_rules = [r for r in pending_rules if r.get("status") == "rejected"]
|
| 512 |
+
|
| 513 |
+
html = f"""
|
| 514 |
+
<style>
|
| 515 |
+
.rules-container {{
|
| 516 |
+
font-family: var(--font-family);
|
| 517 |
+
font-size: var(--font-size-sm);
|
| 518 |
+
}}
|
| 519 |
+
.rules-header {{
|
| 520 |
+
display: flex;
|
| 521 |
+
align-items: center;
|
| 522 |
+
justify-content: space-between;
|
| 523 |
+
margin-bottom: var(--spacing-2);
|
| 524 |
+
}}
|
| 525 |
+
.rules-title {{
|
| 526 |
+
display: flex;
|
| 527 |
+
align-items: center;
|
| 528 |
+
gap: var(--spacing-2);
|
| 529 |
+
}}
|
| 530 |
+
.rules-title-text {{
|
| 531 |
+
font-weight: var(--font-weight-semibold);
|
| 532 |
+
color: var(--color-dark);
|
| 533 |
+
}}
|
| 534 |
+
.archive-toggle {{
|
| 535 |
+
font-size: var(--font-size-xs);
|
| 536 |
+
padding: var(--spacing-1) var(--spacing-2);
|
| 537 |
+
border: var(--border-width) solid var(--color-gray-light);
|
| 538 |
+
border-radius: var(--border-radius-sm);
|
| 539 |
+
background: var(--color-white);
|
| 540 |
+
cursor: pointer;
|
| 541 |
+
transition: all var(--transition-fast);
|
| 542 |
+
font-weight: var(--font-weight-medium);
|
| 543 |
+
}}
|
| 544 |
+
.archive-toggle:hover {{
|
| 545 |
+
background: var(--color-gray-lighter);
|
| 546 |
+
border-color: var(--color-gray);
|
| 547 |
+
}}
|
| 548 |
+
.rule-card {{
|
| 549 |
+
border: var(--border-width) solid var(--color-gray-light);
|
| 550 |
+
border-radius: var(--border-radius);
|
| 551 |
+
padding: var(--spacing-2) var(--spacing-3);
|
| 552 |
+
margin: var(--spacing-2) 0;
|
| 553 |
+
background: var(--color-white);
|
| 554 |
+
transition: all var(--transition-fast);
|
| 555 |
+
box-shadow: var(--shadow-sm);
|
| 556 |
+
}}
|
| 557 |
+
.rule-card:hover {{
|
| 558 |
+
box-shadow: var(--shadow);
|
| 559 |
+
transform: translateY(-1px);
|
| 560 |
+
}}
|
| 561 |
+
.rule-card-accepted {{
|
| 562 |
+
background: #d4edda;
|
| 563 |
+
border-color: var(--color-success);
|
| 564 |
+
}}
|
| 565 |
+
.rule-card-preview {{
|
| 566 |
+
background: #fff3cd;
|
| 567 |
+
border-color: var(--color-warning);
|
| 568 |
+
}}
|
| 569 |
+
.rule-content {{
|
| 570 |
+
display: flex;
|
| 571 |
+
align-items: center;
|
| 572 |
+
justify-content: space-between;
|
| 573 |
+
gap: var(--spacing-2);
|
| 574 |
+
}}
|
| 575 |
+
.rule-info {{
|
| 576 |
+
flex: 1;
|
| 577 |
+
min-width: 0;
|
| 578 |
+
cursor: pointer;
|
| 579 |
+
}}
|
| 580 |
+
.rule-name-row {{
|
| 581 |
+
display: flex;
|
| 582 |
+
align-items: center;
|
| 583 |
+
gap: var(--spacing-2);
|
| 584 |
+
}}
|
| 585 |
+
.rule-toggle-arrow {{
|
| 586 |
+
cursor: pointer;
|
| 587 |
+
font-size: var(--font-size-sm);
|
| 588 |
+
width: 15px;
|
| 589 |
+
text-align: center;
|
| 590 |
+
color: var(--color-gray);
|
| 591 |
+
transition: transform var(--transition-fast);
|
| 592 |
+
}}
|
| 593 |
+
.rule-name {{
|
| 594 |
+
font-weight: var(--font-weight-semibold);
|
| 595 |
+
color: var(--color-dark);
|
| 596 |
+
font-size: var(--font-size-sm);
|
| 597 |
+
}}
|
| 598 |
+
.rule-summary {{
|
| 599 |
+
color: var(--color-gray);
|
| 600 |
+
font-size: var(--font-size-xs);
|
| 601 |
+
margin-top: var(--spacing-1);
|
| 602 |
+
padding-left: 23px;
|
| 603 |
+
}}
|
| 604 |
+
.rule-actions {{
|
| 605 |
+
display: flex;
|
| 606 |
+
gap: var(--spacing-1);
|
| 607 |
+
align-items: center;
|
| 608 |
+
}}
|
| 609 |
+
.rule-btn {{
|
| 610 |
+
padding: var(--spacing-1) var(--spacing-2);
|
| 611 |
+
border-radius: var(--border-radius-sm);
|
| 612 |
+
cursor: pointer;
|
| 613 |
+
font-size: var(--font-size-xs);
|
| 614 |
+
font-weight: var(--font-weight-medium);
|
| 615 |
+
transition: all var(--transition-fast);
|
| 616 |
+
border: var(--border-width) solid transparent;
|
| 617 |
+
}}
|
| 618 |
+
.rule-btn:hover {{
|
| 619 |
+
transform: translateY(-1px);
|
| 620 |
+
box-shadow: var(--shadow-sm);
|
| 621 |
+
}}
|
| 622 |
+
.preview-btn {{
|
| 623 |
+
border-color: var(--color-primary);
|
| 624 |
+
background: var(--color-white);
|
| 625 |
+
color: var(--color-primary);
|
| 626 |
+
}}
|
| 627 |
+
.preview-btn:hover {{
|
| 628 |
+
background: var(--color-primary);
|
| 629 |
+
color: var(--color-white);
|
| 630 |
+
}}
|
| 631 |
+
.accept-btn {{
|
| 632 |
+
background: var(--color-success);
|
| 633 |
+
color: var(--color-white);
|
| 634 |
+
}}
|
| 635 |
+
.accept-btn:hover {{
|
| 636 |
+
background: #218838;
|
| 637 |
+
}}
|
| 638 |
+
.reject-btn {{
|
| 639 |
+
background: var(--color-danger);
|
| 640 |
+
color: var(--color-white);
|
| 641 |
+
}}
|
| 642 |
+
.reject-btn:hover {{
|
| 643 |
+
background: #c82333;
|
| 644 |
+
}}
|
| 645 |
+
.exit-preview-card-btn {{
|
| 646 |
+
border-color: var(--color-warning);
|
| 647 |
+
background: var(--color-white);
|
| 648 |
+
color: var(--color-warning);
|
| 649 |
+
}}
|
| 650 |
+
.exit-preview-card-btn:hover {{
|
| 651 |
+
background: var(--color-warning);
|
| 652 |
+
color: var(--color-white);
|
| 653 |
+
}}
|
| 654 |
+
.rule-details {{
|
| 655 |
+
display: none;
|
| 656 |
+
margin-top: var(--spacing-3);
|
| 657 |
+
padding: var(--spacing-3);
|
| 658 |
+
background: rgba(0,0,0,0.02);
|
| 659 |
+
border-radius: var(--border-radius-sm);
|
| 660 |
+
font-size: var(--font-size-xs);
|
| 661 |
+
}}
|
| 662 |
+
.rule-details-expanded {{
|
| 663 |
+
display: block;
|
| 664 |
+
}}
|
| 665 |
+
.archived-section {{
|
| 666 |
+
margin-top: var(--spacing-4);
|
| 667 |
+
padding-top: var(--spacing-3);
|
| 668 |
+
border-top: var(--border-width) solid var(--color-gray-light);
|
| 669 |
+
}}
|
| 670 |
+
.archived-rule {{
|
| 671 |
+
border: var(--border-width) solid var(--color-gray-light);
|
| 672 |
+
border-radius: var(--border-radius);
|
| 673 |
+
padding: var(--spacing-2) var(--spacing-3);
|
| 674 |
+
margin: var(--spacing-2) 0;
|
| 675 |
+
background: var(--color-gray-lighter);
|
| 676 |
+
opacity: 0.7;
|
| 677 |
+
}}
|
| 678 |
+
.reactivate-btn {{
|
| 679 |
+
padding: var(--spacing-1) var(--spacing-2);
|
| 680 |
+
border: var(--border-width) solid var(--color-success);
|
| 681 |
+
background: var(--color-white);
|
| 682 |
+
color: var(--color-success);
|
| 683 |
+
border-radius: var(--border-radius-sm);
|
| 684 |
+
cursor: pointer;
|
| 685 |
+
font-size: var(--font-size-xs);
|
| 686 |
+
font-weight: var(--font-weight-medium);
|
| 687 |
+
transition: all var(--transition-fast);
|
| 688 |
+
}}
|
| 689 |
+
.reactivate-btn:hover {{
|
| 690 |
+
background: var(--color-success);
|
| 691 |
+
color: var(--color-white);
|
| 692 |
+
}}
|
| 693 |
+
</style>
|
| 694 |
+
<div class="rules-container">
|
| 695 |
+
<!-- Active Rules -->
|
| 696 |
+
<div style="margin-bottom: var(--spacing-4);">
|
| 697 |
+
<div class="rules-header">
|
| 698 |
+
<div class="rules-title">
|
| 699 |
+
<span style="font-size: 1rem;">📋</span>
|
| 700 |
+
<span class="rules-title-text">Active Rules ({len(active_rules)})</span>
|
| 701 |
+
</div>
|
| 702 |
+
{f'<button onclick="toggleArchived()" class="archive-toggle">{"Hide" if archived_rules else "Show"} Archived ▼</button>' if archived_rules else ''}
|
| 703 |
+
</div>
|
| 704 |
+
"""
|
| 705 |
+
|
| 706 |
+
# Active rules - using new sleek cards
|
| 707 |
+
for rule in active_rules:
|
| 708 |
+
if rule.get("status") == "rejected":
|
| 709 |
+
continue
|
| 710 |
+
|
| 711 |
+
html += create_sleek_rule_card(rule)
|
| 712 |
+
|
| 713 |
+
html += """
|
| 714 |
+
</div>
|
| 715 |
+
"""
|
| 716 |
+
|
| 717 |
+
# Archived rules section (if any)
|
| 718 |
+
if archived_rules:
|
| 719 |
+
html += f"""
|
| 720 |
+
<div id="archived-section" style="margin-top: 1rem; display: none;">
|
| 721 |
+
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
| 722 |
+
<span style="font-size: 1rem;">🗄️</span>
|
| 723 |
+
<span style="font-weight: 600; color: #6c757d;">Archived Rules ({len(archived_rules)})</span>
|
| 724 |
+
</div>
|
| 725 |
+
"""
|
| 726 |
+
|
| 727 |
+
for rule in archived_rules:
|
| 728 |
+
rule_id = rule.get('rule_id', rule.get('id', ''))
|
| 729 |
+
archived_rule_name = sanitize_html(rule.get('name', 'Unnamed Rule'))
|
| 730 |
+
html += f"""
|
| 731 |
+
<div class="archived-rule">
|
| 732 |
+
<div style="display: flex; align-items: center; justify-content: space-between;">
|
| 733 |
+
<span style="color: var(--color-gray); font-size: var(--font-size-sm);">{archived_rule_name}</span>
|
| 734 |
+
<button class="reactivate-btn" data-rule-id="{rule_id}">Reactivate</button>
|
| 735 |
+
</div>
|
| 736 |
+
</div>
|
| 737 |
+
"""
|
| 738 |
+
|
| 739 |
+
html += """
|
| 740 |
+
</div>
|
| 741 |
+
"""
|
| 742 |
+
|
| 743 |
+
html += """
|
| 744 |
+
</div>
|
| 745 |
+
<script>
|
| 746 |
+
function toggleArchived() {
|
| 747 |
+
const section = document.getElementById('archived-section');
|
| 748 |
+
if (section) {
|
| 749 |
+
section.style.display = section.style.display === 'none' ? 'block' : 'none';
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
</script>
|
| 753 |
+
"""
|
| 754 |
+
|
| 755 |
+
return html
|
| 756 |
+
|
| 757 |
+
|
| 758 |
+
def get_preview_rules_count(pending_rules: List[Dict[str, Any]]) -> int:
|
| 759 |
+
"""Get the number of rules currently in preview status"""
|
| 760 |
+
return len([rule for rule in pending_rules if rule.get("status") == "preview"])
|
data/emails.json
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "msg_001",
|
| 4 |
+
"threadId": "thread_001",
|
| 5 |
+
"from_email": "[email protected]",
|
| 6 |
+
"from_name": "Sarah Johnson",
|
| 7 |
+
"subject": "Dinner plans for tonight?",
|
| 8 |
+
"snippet": "Hey honey, What do you think about Italian food tonight? Th...",
|
| 9 |
+
"body": "Hey honey,\n\nWhat do you think about Italian food tonight? There's that new place downtown that opened last week - Bella Vista. I heard they have amazing homemade pasta.\n\nI can make a reservation for 7:30 PM if that works for you. Let me know!\n\nLove,\nSarah",
|
| 10 |
+
"date": "2024-01-15T18:30:00Z",
|
| 11 |
+
"labels": ["INBOX", "PERSONAL"],
|
| 12 |
+
"folder": "INBOX"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"id": "msg_002",
|
| 16 |
+
"threadId": "thread_002",
|
| 17 |
+
"from_email": "[email protected]",
|
| 18 |
+
"from_name": "GitHub",
|
| 19 |
+
"subject": "[PR] Review requested: Update authentication flow",
|
| 20 |
+
"snippet": "You have been requested to review a pull request in the main...",
|
| 21 |
+
"body": "You have been requested to review a pull request in the main repository.\n\n**Pull Request #1234: Update authentication flow**\n\nAuthor: @johndoe\nBranch: feature/auth-update → main\n\n### Description\nThis PR updates the authentication flow to use OAuth 2.0 with PKCE for improved security.\n\n### Changes\n- Updated login component to use new auth service\n- Added PKCE challenge generation\n- Updated token refresh logic\n- Added comprehensive tests\n\n[View Pull Request](https://github.com/company/repo/pull/1234)\n\n---\nYou are receiving this because you were requested to review.",
|
| 22 |
+
"date": "2024-01-15T14:22:00Z",
|
| 23 |
+
"labels": ["INBOX", "WORK", "IMPORTANT"],
|
| 24 |
+
"folder": "INBOX"
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"id": "msg_003",
|
| 28 |
+
"threadId": "thread_003",
|
| 29 |
+
"from_email": "[email protected]",
|
| 30 |
+
"from_name": "Uber Eats",
|
| 31 |
+
"subject": "30% off your next order!",
|
| 32 |
+
"snippet": "Hungry? Use code SAVE30 for 30% off your next order. Valid u...",
|
| 33 |
+
"body": "🍔 Hungry? We've got you covered!\n\nUse code **SAVE30** for 30% off your next order.\n\nValid until January 31, 2024. Maximum discount $15.\n\n**Popular restaurants near you:**\n- Chipotle Mexican Grill (15-25 min)\n- Five Guys Burgers (20-30 min)\n- Sweetgreen (10-20 min)\n- Shake Shack (25-35 min)\n\n[Order Now](https://ubereats.com)\n\nTerms apply. Cannot be combined with other offers.",
|
| 34 |
+
"date": "2024-01-15T12:45:00Z",
|
| 35 |
+
"labels": ["INBOX", "PROMOTIONS"],
|
| 36 |
+
"folder": "INBOX"
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"id": "msg_004",
|
| 40 |
+
"threadId": "thread_004",
|
| 41 |
+
"from_email": "[email protected]",
|
| 42 |
+
"from_name": "Morning Brew Newsletter",
|
| 43 |
+
"subject": "☀️ Markets hit new highs",
|
| 44 |
+
"snippet": "Good morning! Here's what you need to know today: S&P 500 re...",
|
| 45 |
+
"date": "2024-01-15T06:00:00Z",
|
| 46 |
+
"labels": ["INBOX", "UPDATES", "NEWSLETTERS"],
|
| 47 |
+
"folder": "INBOX"
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"id": "msg_005",
|
| 51 |
+
"threadId": "thread_005",
|
| 52 |
+
"from_email": "[email protected]",
|
| 53 |
+
"from_name": "TechCrunch Newsletter",
|
| 54 |
+
"subject": "Daily: AI Startups Raise Record Funding",
|
| 55 |
+
"snippet": "Good morning. Here's your daily digest of the biggest tech...",
|
| 56 |
+
"date": "2024-01-15T07:15:00Z",
|
| 57 |
+
"labels": ["INBOX", "UPDATES", "NEWSLETTERS"],
|
| 58 |
+
"folder": "INBOX"
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"id": "msg_006",
|
| 62 |
+
"threadId": "thread_006",
|
| 63 |
+
"from_email": "[email protected]",
|
| 64 |
+
"from_name": "Michael Chen",
|
| 65 |
+
"subject": "Urgent: Q1 Budget Review",
|
| 66 |
+
"snippet": "Hi team, I hope this email finds you well. As discussed in...",
|
| 67 |
+
"date": "2024-01-15T09:30:00Z",
|
| 68 |
+
"labels": ["INBOX", "WORK", "IMPORTANT", "URGENT"],
|
| 69 |
+
"folder": "INBOX"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"id": "msg_007",
|
| 73 |
+
"threadId": "thread_007",
|
| 74 |
+
"from_email": "[email protected]",
|
| 75 |
+
"from_name": "Bank of America",
|
| 76 |
+
"subject": "Low balance alert",
|
| 77 |
+
"snippet": "Your checking account balance has fallen below $100. Current...",
|
| 78 |
+
"date": "2024-01-15T10:15:00Z",
|
| 79 |
+
"labels": ["INBOX", "IMPORTANT", "FINANCE", "ALERTS"],
|
| 80 |
+
"folder": "INBOX"
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "msg_008",
|
| 84 |
+
"threadId": "thread_008",
|
| 85 |
+
"from_email": "[email protected]",
|
| 86 |
+
"from_name": "Amazon",
|
| 87 |
+
"subject": "Your order has been shipped",
|
| 88 |
+
"snippet": "Great news! Your order #123-456789 has been shipped and is...",
|
| 89 |
+
"date": "2024-01-14T16:45:00Z",
|
| 90 |
+
"labels": ["INBOX", "PURCHASES", "SHOPPING"],
|
| 91 |
+
"folder": "INBOX"
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"id": "msg_009",
|
| 95 |
+
"threadId": "thread_002",
|
| 96 |
+
"from_email": "[email protected]",
|
| 97 |
+
"from_name": "GitHub",
|
| 98 |
+
"subject": "[PR] Review requested: Update authentication flow",
|
| 99 |
+
"snippet": "You have been requested to review a pull request in the main...",
|
| 100 |
+
"date": "2024-01-15T14:22:00Z",
|
| 101 |
+
"labels": ["WORK", "IMPORTANT"],
|
| 102 |
+
"folder": "WORK"
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"id": "msg_010",
|
| 106 |
+
"threadId": "thread_003",
|
| 107 |
+
"from_email": "[email protected]",
|
| 108 |
+
"from_name": "Uber Eats",
|
| 109 |
+
"subject": "30% off your next order!",
|
| 110 |
+
"snippet": "Hungry? Use code SAVE30 for 30% off your next order. Valid u...",
|
| 111 |
+
"date": "2024-01-14T12:45:00Z",
|
| 112 |
+
"labels": ["READING", "PROMOTIONS"],
|
| 113 |
+
"folder": "READING"
|
| 114 |
+
}
|
| 115 |
+
]
|
data/rules.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"rules": [
|
| 3 |
+
{
|
| 4 |
+
"rule_id": "test_rule_1",
|
| 5 |
+
"name": "Move Newsletters to Reading",
|
| 6 |
+
"description": "Automatically move newsletter emails to a Reading folder",
|
| 7 |
+
"conditions": [
|
| 8 |
+
{
|
| 9 |
+
"field": "from",
|
| 10 |
+
"operator": "contains",
|
| 11 |
+
"value": "newsletter"
|
| 12 |
+
}
|
| 13 |
+
],
|
| 14 |
+
"actions": [
|
| 15 |
+
{
|
| 16 |
+
"type": "move",
|
| 17 |
+
"parameters": {
|
| 18 |
+
"folder": "Reading"
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
],
|
| 22 |
+
"impact": "This will organize your newsletters in one place",
|
| 23 |
+
"keywords": [
|
| 24 |
+
"newsletter",
|
| 25 |
+
"reading",
|
| 26 |
+
"organize"
|
| 27 |
+
]
|
| 28 |
+
}
|
| 29 |
+
]
|
| 30 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core dependencies
|
| 2 |
+
gradio>=4.44.1
|
| 3 |
+
gradio-client
|
| 4 |
+
|
| 5 |
+
# Environment variables
|
| 6 |
+
python-dotenv
|
| 7 |
+
|
| 8 |
+
# LLM dependencies (for simple_agent.py)
|
| 9 |
+
langchain
|
| 10 |
+
langchain-openai
|
| 11 |
+
openai
|
| 12 |
+
|
| 13 |
+
# HTTP client for Modal backend
|
| 14 |
+
requests
|
| 15 |
+
httpx
|
| 16 |
+
|
| 17 |
+
# Utilities
|
| 18 |
+
python-dateutil
|
| 19 |
+
|
| 20 |
+
# Security (for HTML sanitization)
|
| 21 |
+
bleach
|
| 22 |
+
|
| 23 |
+
# Type hints
|
| 24 |
+
pydantic
|