olety commited on
Commit
028cd37
·
verified ·
0 Parent(s):

Initial deployment

Browse files
Files changed (47) hide show
  1. IMPLEMENTATION_SUMMARY.md +82 -0
  2. README.md +167 -0
  3. app.py +40 -0
  4. archive/ui_old/__init__.py +8 -0
  5. archive/ui_old/__pycache__/__init__.cpython-313.pyc +0 -0
  6. archive/ui_old/__pycache__/main.cpython-313.pyc +0 -0
  7. archive/ui_old/archive/ui_chat.py +165 -0
  8. archive/ui_old/archive/ui_fixed.py +974 -0
  9. archive/ui_old/archive/ui_gradio_render.py +414 -0
  10. archive/ui_old/archive/ui_handlers_fix.py +41 -0
  11. archive/ui_old/archive/ui_refactored_example.py +384 -0
  12. archive/ui_old/archive/ui_simplified.py +137 -0
  13. archive/ui_old/archive/ui_streaming.py +144 -0
  14. archive/ui_old/archive/ui_streaming_fixed.py +172 -0
  15. archive/ui_old/handlers/__init__.py +7 -0
  16. archive/ui_old/handlers/preview.py +123 -0
  17. archive/ui_old/main.py +769 -0
  18. archive/ui_old/utils/__init__.py +26 -0
  19. archive/ui_old/utils/display.py +351 -0
  20. archive/ui_old/utils/helpers.py +29 -0
  21. components/.DS_Store +0 -0
  22. components/__init__.py +1 -0
  23. components/__pycache__/__init__.cpython-313.pyc +0 -0
  24. components/__pycache__/agent.cpython-313.pyc +0 -0
  25. components/__pycache__/agent_streaming.cpython-313.pyc +0 -0
  26. components/__pycache__/mcp_client.cpython-313.pyc +0 -0
  27. components/__pycache__/models.cpython-313.pyc +0 -0
  28. components/__pycache__/session_manager.cpython-313.pyc +0 -0
  29. components/__pycache__/simple_agent.cpython-313.pyc +0 -0
  30. components/__pycache__/ui.cpython-313.pyc +0 -0
  31. components/__pycache__/ui_chat.cpython-313.pyc +0 -0
  32. components/__pycache__/ui_streaming.cpython-313.pyc +0 -0
  33. components/__pycache__/ui_streaming_fixed.cpython-313.pyc +0 -0
  34. components/__pycache__/ui_tools.cpython-313.pyc +0 -0
  35. components/__pycache__/ui_utils.cpython-313.pyc +0 -0
  36. components/agent_streaming.py +145 -0
  37. components/mcp_client.py +587 -0
  38. components/models.py +40 -0
  39. components/session_manager.py +114 -0
  40. components/simple_agent.py +553 -0
  41. components/ui.py +2237 -0
  42. components/ui_chat.py +176 -0
  43. components/ui_tools.py +165 -0
  44. components/ui_utils.py +760 -0
  45. data/emails.json +115 -0
  46. data/rules.json +30 -0
  47. 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