Base code
BIN
frontend/build/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
15
frontend/build/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "Alwrity",
|
||||
"name": "Alwrity - AI Content Creation Platform",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
frontend/build/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
605
frontend/docs/linkedin_factual_google_grounded_url_content.md
Normal file
@@ -0,0 +1,605 @@
|
||||
# LinkedIn Factual Google Grounded URL Content Enhancement Plan
|
||||
|
||||
## 📋 **Executive Summary**
|
||||
|
||||
This document outlines ALwrity's comprehensive plan to enhance LinkedIn content quality from basic AI generation to enterprise-grade, factually grounded content using Google AI's advanced capabilities. The implementation will integrate Google Search grounding and URL context tools to provide LinkedIn professionals with credible, current, and industry-relevant content.
|
||||
|
||||
**🟢 IMPLEMENTATION STATUS: Phase 1 Native Grounding Completed**
|
||||
|
||||
## 🎯 **Problem Statement**
|
||||
|
||||
### **Current State Issues**
|
||||
- **Generic AI Content**: Produces bland, non-specific content lacking industry relevance
|
||||
- **No Source Verification**: Content claims lack factual backing or citations
|
||||
- **Outdated Information**: AI knowledge cutoff limits current industry insights
|
||||
- **Low Professional Credibility**: Content doesn't meet enterprise LinkedIn standards
|
||||
- **No Industry Context**: Fails to leverage current trends, reports, or expert insights
|
||||
- **Mock Research System**: Current `_conduct_research` method returns simulated data
|
||||
- **Limited Grounding**: Content not factually verified or source-attributed
|
||||
|
||||
### **Business Impact**
|
||||
- **User Dissatisfaction**: Professional users expect higher quality content
|
||||
- **Competitive Disadvantage**: Other tools may offer better content quality
|
||||
- **Trust Issues**: Unverified content damages brand credibility
|
||||
- **Limited Adoption**: Enterprise users won't adopt low-quality content tools
|
||||
|
||||
## 🚀 **Solution Overview**
|
||||
|
||||
### **Google AI Integration Strategy**
|
||||
1. **Google Search Grounding**: Real-time web search for current industry information
|
||||
2. **URL Context Integration**: Specific source grounding from authoritative URLs
|
||||
3. **Citation System**: Inline source attribution for all factual claims
|
||||
4. **Quality Assurance**: Automated fact-checking and source validation
|
||||
5. **Enhanced Gemini Provider**: Grounded content generation with source integration
|
||||
|
||||
### **Expected Outcomes**
|
||||
- **Enterprise-Grade Content**: Professional quality suitable for LinkedIn professionals
|
||||
- **Factual Accuracy**: All claims backed by current, verifiable sources
|
||||
- **Industry Relevance**: Content grounded in latest trends and insights
|
||||
- **Trust Building**: Verifiable sources increase user confidence and adoption
|
||||
|
||||
## 🏗️ **Technical Architecture**
|
||||
|
||||
### **Core Components**
|
||||
|
||||
#### **1. Enhanced Gemini Provider Module** ✅ **IMPLEMENTED**
|
||||
- **Grounded Content Generation**: AI content generation with source integration
|
||||
- **Citation Engine**: Automatic inline citation generation and management
|
||||
- **Source Integration**: Seamless incorporation of research data into content
|
||||
- **Quality Validation**: Content quality assessment and scoring
|
||||
- **Fallback Systems**: Graceful degradation when grounding fails
|
||||
|
||||
**Implementation Details:**
|
||||
- **File**: `backend/services/llm_providers/gemini_grounded_provider.py`
|
||||
- **Class**: `GeminiGroundedProvider`
|
||||
- **Key Methods**:
|
||||
- `generate_grounded_content()` - Main content generation with sources
|
||||
- `_build_grounded_prompt()` - Source-integrated prompt building
|
||||
- `_add_citations()` - Automatic citation insertion
|
||||
- `_assess_content_quality()` - Quality scoring and validation
|
||||
|
||||
#### **2. Real Research Service** ✅ **IMPLEMENTED**
|
||||
- **Google Custom Search API**: Industry-specific search with credibility scoring
|
||||
- **Source Ranking Algorithm**: Prioritize sources by credibility, recency, and relevance
|
||||
- **Domain Authority Assessment**: Evaluate source reliability and expertise
|
||||
- **Content Extraction**: Extract relevant insights and statistics from sources
|
||||
- **Real-time Updates**: Current information from the last month
|
||||
|
||||
**Implementation Details:**
|
||||
- **File**: `backend/services/research/google_search_service.py`
|
||||
- **Class**: `GoogleSearchService`
|
||||
- **Key Methods**:
|
||||
- `search_industry_trends()` - Main search functionality
|
||||
- `_build_search_query()` - Intelligent query construction
|
||||
- `_perform_search()` - API call management with retry logic
|
||||
- `_process_search_results()` - Result processing and scoring
|
||||
- `_calculate_relevance_score()` - Relevance scoring algorithm
|
||||
- `_calculate_credibility_score()` - Source credibility assessment
|
||||
|
||||
#### **3. Citation Management System** ✅ **IMPLEMENTED**
|
||||
- **Inline Citation Formatting**: [Source 1], [Source 2] style citations
|
||||
- **Citation Validation**: Ensure all claims have proper source attribution
|
||||
- **Source List Generation**: Comprehensive list of sources with links
|
||||
- **Citation Coverage Analysis**: Track percentage of claims with citations
|
||||
|
||||
**Implementation Details:**
|
||||
- **File**: `backend/services/citation/citation_manager.py`
|
||||
- **Class**: `CitationManager`
|
||||
- **Key Methods**:
|
||||
- `add_citations()` - Insert citations into content
|
||||
- `validate_citations()` - Verify citation completeness
|
||||
- `generate_source_list()` - Create formatted source references
|
||||
- `extract_citations()` - Parse existing citations from content
|
||||
- `_identify_citation_patterns()` - Pattern recognition for citations
|
||||
|
||||
#### **4. Content Quality Analyzer** ✅ **IMPLEMENTED**
|
||||
- **Factual Accuracy Scoring**: Assess content against source verification
|
||||
- **Professional Tone Analysis**: Evaluate enterprise-appropriate language
|
||||
- **Industry Relevance Metrics**: Measure topic-specific content alignment
|
||||
- **Overall Quality Scoring**: Composite score for content assessment
|
||||
|
||||
**Implementation Details:**
|
||||
- **File**: `backend/services/quality/content_analyzer.py`
|
||||
- **Class**: `ContentQualityAnalyzer`
|
||||
- **Key Methods**:
|
||||
- `analyze_content_quality()` - Main quality assessment
|
||||
- `_assess_factual_accuracy()` - Source verification scoring
|
||||
- `_assess_professional_tone()` - Language appropriateness analysis
|
||||
- `_assess_industry_relevance()` - Topic alignment scoring
|
||||
- `_calculate_overall_score()` - Composite quality calculation
|
||||
|
||||
#### **5. Enhanced LinkedIn Service** ✅ **IMPLEMENTED**
|
||||
- **Integrated Grounding**: Seamless integration of all grounding services
|
||||
- **Content Generation**: Enhanced methods for all LinkedIn content types
|
||||
- **Research Integration**: Real research with fallback to mock data
|
||||
- **Quality Metrics**: Comprehensive content quality reporting
|
||||
- **Grounding Status**: Detailed grounding operation tracking
|
||||
|
||||
**Implementation Details:**
|
||||
- **File**: `backend/services/linkedin_service.py`
|
||||
- **Class**: `LinkedInService` (renamed from `LinkedInContentService`)
|
||||
- **Key Methods**:
|
||||
- `generate_linkedin_post()` - Enhanced post generation with grounding
|
||||
- `generate_linkedin_article()` - Research-backed article creation
|
||||
- `generate_linkedin_carousel()` - Grounded carousel generation
|
||||
- `generate_linkedin_video_script()` - Script generation with sources
|
||||
- `_conduct_research()` - Real Google search with fallback
|
||||
- `_generate_grounded_*_content()` - Grounded content generation methods
|
||||
|
||||
#### **6. Enhanced Data Models** ✅ **IMPLEMENTED**
|
||||
- **Grounding Support**: New fields for sources, citations, and quality metrics
|
||||
- **Enhanced Responses**: Comprehensive response models with grounding data
|
||||
- **Quality Metrics**: Detailed content quality assessment models
|
||||
- **Citation Models**: Structured citation and source management
|
||||
|
||||
**Implementation Details:**
|
||||
- **File**: `backend/models/linkedin_models.py`
|
||||
- **New Models**:
|
||||
- `GroundingLevel` - Enum for grounding levels (none, basic, enhanced, enterprise)
|
||||
- `ContentQualityMetrics` - Comprehensive quality scoring
|
||||
- `Citation` - Inline citation structure
|
||||
- Enhanced `ResearchSource` with credibility and domain authority
|
||||
- Enhanced response models with grounding status and quality metrics
|
||||
|
||||
### **Data Flow Architecture**
|
||||
```
|
||||
User Request → Content Type + Industry + Preferences
|
||||
↓
|
||||
Real Google Search → Industry-Relevant Current Sources
|
||||
↓
|
||||
Source Analysis → Identify Most Credible and Recent Sources
|
||||
↓
|
||||
Grounded Content Generation → AI Content with Source Integration
|
||||
↓
|
||||
Citation Addition → Automatic Inline Source Attribution
|
||||
↓
|
||||
Quality Validation → Ensure All Claims Are Properly Sourced
|
||||
↓
|
||||
Output Delivery → Professional Content with Inline Citations
|
||||
```
|
||||
|
||||
## 🔧 **Implementation Phases**
|
||||
|
||||
### **Phase 1: Native Google Search Grounding** ✅ **COMPLETED**
|
||||
|
||||
#### **Objectives** ✅ **ACHIEVED**
|
||||
- ✅ Implement native Google Search grounding functionality via Gemini API
|
||||
- ✅ Establish automatic citation system from grounding metadata
|
||||
- ✅ Enable automatic industry-relevant searches with no manual intervention
|
||||
- ✅ Build source verification and credibility ranking from grounding chunks
|
||||
|
||||
#### **Key Features** ✅ **IMPLEMENTED**
|
||||
- ✅ **Native Search Integration**: Gemini API automatically handles search queries and processing
|
||||
- ✅ **Automatic Source Extraction**: Sources extracted from `groundingMetadata.groundingChunks`
|
||||
- ✅ **Citation Generation**: Automatic inline citations from `groundingMetadata.groundingSupports`
|
||||
- ✅ **Quality Validation**: Content quality assessment with source coverage metrics
|
||||
- ✅ **Real-time Information**: Current data from the last month via native Google Search
|
||||
|
||||
#### **Technical Requirements** ✅ **COMPLETED**
|
||||
- ✅ Google GenAI library integration (`google-genai>=0.3.0`)
|
||||
- ✅ Native `google_search` tool configuration in Gemini API
|
||||
- ✅ Grounding metadata processing and source extraction
|
||||
- ✅ Citation formatting and link management from grounding data
|
||||
- ✅ Enhanced Gemini provider with native grounding capabilities
|
||||
|
||||
#### **Files Created/Modified** ✅ **COMPLETED**
|
||||
- ✅ `backend/services/llm_providers/gemini_grounded_provider.py` - Native grounding provider
|
||||
- ✅ `backend/services/linkedin_service.py` - Updated for native grounding
|
||||
- ✅ `backend/requirements.txt` - Updated Google GenAI dependencies
|
||||
- ✅ `backend/test_native_grounding.py` - Native grounding test script
|
||||
- ✅ **Architecture Simplified**: Removed custom Google Search service dependency
|
||||
- ✅ **Native Integration**: Direct Gemini API grounding tool usage
|
||||
- ✅ **Automatic Workflow**: Model handles search, processing, and citation automatically
|
||||
|
||||
### **Phase 2: URL Context Integration** 🔄 **PLANNED**
|
||||
|
||||
#### **Objectives**
|
||||
- Enable specific source grounding from user-provided URLs
|
||||
- Integrate curated industry report library
|
||||
- Implement competitor analysis capabilities
|
||||
- Build source management and organization system
|
||||
|
||||
#### **Key Features**
|
||||
- **URL Input System**: Allow users to provide relevant source URLs
|
||||
- **Industry Report Library**: Curated collection of authoritative sources
|
||||
- **Competitor Analysis**: Industry benchmarking and insights
|
||||
- **Source Categorization**: Organize sources by industry, type, and credibility
|
||||
- **Content Extraction**: Pull relevant information from specific URLs
|
||||
|
||||
#### **Technical Requirements**
|
||||
- Google AI API integration with `url_context` tool
|
||||
- URL validation and content extraction
|
||||
- Source categorization and tagging system
|
||||
- Content grounding in specific sources
|
||||
|
||||
### **Phase 3: Advanced Features** 📋 **PLANNED**
|
||||
|
||||
#### **Objectives**
|
||||
- Implement advanced analytics and performance tracking
|
||||
- Build AI-powered source credibility scoring
|
||||
- Enable multi-language industry insights
|
||||
- Create custom source integration capabilities
|
||||
|
||||
#### **Key Features**
|
||||
- **Performance Analytics**: Track content quality and user satisfaction
|
||||
- **Advanced Source Scoring**: AI-powered credibility assessment
|
||||
- **Multi-language Support**: International industry insights
|
||||
- **Custom Source Integration**: User-defined source libraries
|
||||
- **Quality Metrics Dashboard**: Real-time content quality monitoring
|
||||
|
||||
## 📊 **Content Quality Improvements**
|
||||
|
||||
### **Before vs. After Comparison**
|
||||
|
||||
| Aspect | Current State | Enhanced State |
|
||||
|--------|---------------|----------------|
|
||||
| **Factual Accuracy** | Generic AI claims | All claims backed by current sources |
|
||||
| **Industry Relevance** | Generic content | Grounded in latest industry trends |
|
||||
| **Source Verification** | No sources | Inline citations with clickable links |
|
||||
| **Information Recency** | Knowledge cutoff limited | Real-time current information |
|
||||
| **Professional Credibility** | Basic AI quality | Enterprise-grade content |
|
||||
| **User Trust** | Low (unverified content) | High (verifiable sources) |
|
||||
| **Research Quality** | Mock/simulated data | Real Google search results |
|
||||
| **Citation Coverage** | 0% | 95%+ of claims cited |
|
||||
|
||||
### **Specific LinkedIn Content Enhancements**
|
||||
|
||||
#### **Posts & Articles**
|
||||
- **Trending Topics**: Current industry discussions and hashtags
|
||||
- **Expert Insights**: Quotes and insights from industry leaders
|
||||
- **Data-Driven Content**: Statistics and research findings
|
||||
- **Competitive Analysis**: Industry benchmarking and insights
|
||||
- **Source Attribution**: Every claim backed by verifiable sources
|
||||
|
||||
#### **Carousels & Presentations**
|
||||
- **Visual Data**: Charts and graphs from industry reports
|
||||
- **Trend Analysis**: Current market movements and predictions
|
||||
- **Case Studies**: Real examples from industry leaders
|
||||
- **Best Practices**: Current industry standards and recommendations
|
||||
- **Citation Integration**: Source references for all data points
|
||||
|
||||
## 🎯 **Implementation Priorities**
|
||||
|
||||
### **High Priority (Phase 1)** ✅ **COMPLETED**
|
||||
1. ✅ **Google Search Integration**: Core grounding functionality
|
||||
2. ✅ **Citation System**: Inline source attribution
|
||||
3. ✅ **Enhanced Actions**: Search-enabled content generation
|
||||
4. ✅ **Quality Validation**: Source verification and fact-checking
|
||||
5. ✅ **Enhanced Gemini Provider**: Grounded content generation
|
||||
|
||||
### **Medium Priority (Phase 2)** 🔄 **NEXT**
|
||||
1. **URL Context Integration**: Specific source grounding
|
||||
2. **Industry Report Integration**: Curated source library
|
||||
3. **Competitor Analysis**: Industry benchmarking tools
|
||||
4. **Trend Monitoring**: Real-time industry insights
|
||||
5. **Source Management**: User control over source selection
|
||||
|
||||
### **Low Priority (Phase 3)** 📋 **PLANNED**
|
||||
1. **Advanced Analytics**: Content performance tracking
|
||||
2. **Source Ranking**: AI-powered source credibility scoring
|
||||
3. **Multi-language Support**: International industry insights
|
||||
4. **Custom Source Integration**: User-defined source libraries
|
||||
5. **Quality Dashboard**: Real-time content quality monitoring
|
||||
|
||||
## 💰 **Business Impact & ROI**
|
||||
|
||||
### **User Experience Improvements**
|
||||
- **Professional Credibility**: Enterprise-level content quality
|
||||
- **Time Savings**: Research-backed content in minutes vs. hours
|
||||
- **Trust Building**: Verifiable sources increase user confidence
|
||||
- **Industry Relevance**: Always current and relevant content
|
||||
- **Source Transparency**: Users can verify all claims
|
||||
|
||||
### **Competitive Advantages**
|
||||
- **Unique Positioning**: First LinkedIn tool with grounded AI content
|
||||
- **Quality Differentiation**: Professional-grade vs. generic AI content
|
||||
- **Trust Leadership**: Source verification builds user loyalty
|
||||
- **Industry Expertise**: Deep industry knowledge and insights
|
||||
- **Enterprise Appeal**: Suitable for professional and corporate use
|
||||
|
||||
### **Revenue Impact**
|
||||
- **Premium Pricing**: Enterprise-grade features justify higher pricing
|
||||
- **User Retention**: Higher quality content increases user loyalty
|
||||
- **Market Expansion**: Appeal to enterprise and professional users
|
||||
- **Partnership Opportunities**: Industry report providers and publishers
|
||||
- **Subscription Upgrades**: Premium grounding features drive upgrades
|
||||
|
||||
## 🔒 **Technical Requirements & Dependencies**
|
||||
|
||||
### **Google AI API Requirements** ✅ **IMPLEMENTED**
|
||||
- ✅ **API Access**: Google AI API with grounding capabilities
|
||||
- ✅ **Search API**: Google Custom Search API for industry research
|
||||
- ✅ **Authentication**: Proper API key management and security
|
||||
- ✅ **Rate Limits**: Understanding and managing API usage limits
|
||||
- ✅ **Cost Management**: Monitoring and optimizing API costs
|
||||
|
||||
### **Infrastructure Requirements** ✅ **COMPLETED**
|
||||
- ✅ **Backend Services**: Enhanced content generation pipeline
|
||||
- ✅ **Database**: Source management and citation storage
|
||||
- ✅ **Caching**: Search result caching for performance
|
||||
- ✅ **Monitoring**: API usage and content quality monitoring
|
||||
- ✅ **Fallback Systems**: Graceful degradation when APIs fail
|
||||
|
||||
### **Security & Compliance**
|
||||
- **Data Privacy**: Secure handling of user content and sources
|
||||
- **Source Validation**: Ensuring sources are safe and appropriate
|
||||
- **Content Moderation**: Filtering inappropriate or unreliable sources
|
||||
- **Compliance**: Meeting industry and regulatory requirements
|
||||
- **API Security**: Secure API key management and usage
|
||||
|
||||
## 📈 **Success Metrics & KPIs**
|
||||
|
||||
### **Content Quality Metrics**
|
||||
- **Source Verification Rate**: Percentage of claims with citations
|
||||
- **Source Credibility Score**: Average credibility of used sources
|
||||
- **Content Freshness**: Age of information used in content
|
||||
- **User Satisfaction**: Content quality ratings and feedback
|
||||
- **Citation Coverage**: Percentage of factual claims properly cited
|
||||
|
||||
### **Business Metrics**
|
||||
- **User Adoption**: Increase in enterprise user adoption
|
||||
- **Content Usage**: Higher engagement with generated content
|
||||
- **User Retention**: Improved user loyalty and retention
|
||||
- **Revenue Growth**: Increased pricing and subscription rates
|
||||
- **Premium Feature Usage**: Adoption of grounding features
|
||||
|
||||
### **Technical Metrics**
|
||||
- **API Performance**: Response times and reliability
|
||||
- **Search Accuracy**: Relevance of search results
|
||||
- **Citation Accuracy**: Proper source attribution
|
||||
- **System Uptime**: Overall system reliability
|
||||
- **Fallback Success Rate**: Successful degradation when needed
|
||||
|
||||
## 🚧 **Risk Assessment & Mitigation**
|
||||
|
||||
### **Technical Risks**
|
||||
- **API Dependencies**: Google AI API availability and changes
|
||||
- **Performance Issues**: Search integration impact on response times
|
||||
- **Cost Overruns**: Uncontrolled API usage and costs
|
||||
- **Integration Complexity**: Technical challenges in implementation
|
||||
|
||||
### **Mitigation Strategies** ✅ **IMPLEMENTED**
|
||||
- ✅ **API Redundancy**: Backup content generation methods
|
||||
- ✅ **Performance Optimization**: Efficient search and caching strategies
|
||||
- ✅ **Cost Controls**: Usage monitoring and optimization
|
||||
- ✅ **Phased Implementation**: Gradual rollout to manage complexity
|
||||
- ✅ **Fallback Systems**: Graceful degradation to existing methods
|
||||
|
||||
### **Business Risks**
|
||||
- **User Adoption**: Resistance to new features or workflows
|
||||
- **Quality Expectations**: Meeting high enterprise standards
|
||||
- **Competitive Response**: Other tools implementing similar features
|
||||
- **Market Changes**: Shifts in user needs or preferences
|
||||
|
||||
### **Mitigation Strategies**
|
||||
- **User Education**: Clear communication of benefits and value
|
||||
- **Quality Assurance**: Rigorous testing and validation
|
||||
- **Continuous Innovation**: Staying ahead of competition
|
||||
- **User Feedback**: Regular input and iteration
|
||||
- **Beta Testing**: Gradual rollout with user feedback
|
||||
|
||||
## 🔄 **Migration Strategy**
|
||||
|
||||
### **Current System Analysis** ✅ **COMPLETED**
|
||||
- ✅ **LinkedIn Service**: Well-structured with research capabilities
|
||||
- ✅ **Gemini Provider**: Google AI integration already in place
|
||||
- ✅ **Mock Research**: Current `_conduct_research` method
|
||||
- ✅ **CopilotKit Actions**: Frontend actions for content generation
|
||||
|
||||
### **Migration Approach** ✅ **IMPLEMENTED**
|
||||
- ✅ **Incremental Enhancement**: Build on existing infrastructure
|
||||
- ✅ **Feature Flags**: Enable/disable grounding features
|
||||
- ✅ **Backward Compatibility**: Maintain existing functionality
|
||||
- ✅ **User Choice**: Allow users to opt-in to grounding features
|
||||
- ✅ **Performance Monitoring**: Track impact on existing systems
|
||||
|
||||
### **Rollout Plan** 🔄 **IN PROGRESS**
|
||||
- ✅ **Phase 1**: Core grounding for posts and articles
|
||||
- 🔄 **Phase 2**: Enhanced source management and URL context
|
||||
- 📋 **Phase 3**: Advanced analytics and quality monitoring
|
||||
- 🔄 **User Groups**: Start with power users, expand gradually
|
||||
- 🔄 **Feedback Integration**: Continuous improvement based on usage
|
||||
|
||||
## 🔧 **Recent Fixes Applied**
|
||||
|
||||
### **Service Refactoring & Code Organization** ✅ **COMPLETED**
|
||||
- ✅ **LinkedIn Service Refactoring**: Extracted quality metrics handling to separate `QualityHandler` module
|
||||
- ✅ **Content Generation Extraction**: Moved large post and article generation methods to `ContentGenerator` module
|
||||
- ✅ **Research Logic Extraction**: Extracted research handling logic to `ResearchHandler` module
|
||||
- ✅ **Code Organization**: Created `backend/services/linkedin/` package for better code structure
|
||||
- ✅ **Quality Metrics Extraction**: Moved complex quality metrics creation logic to dedicated handler
|
||||
- ✅ **Maintainability Improvement**: Significantly reduced `linkedin_service.py` complexity and improved readability
|
||||
- ✅ **Function Size Reduction**: Broke down large functions into focused, manageable modules
|
||||
|
||||
### **Critical Bug Fixes** ✅ **COMPLETED**
|
||||
- ✅ **Citation Processing Fixed**: Updated `CitationManager` to handle both Dict and ResearchSource Pydantic models
|
||||
- ✅ **Quality Analysis Fixed**: Updated `ContentQualityAnalyzer` to work with ResearchSource objects
|
||||
- ✅ **Data Type Compatibility**: Resolved `.get()` method calls on Pydantic model objects
|
||||
- ✅ **Service Integration**: All citation and quality services now work correctly with native grounding
|
||||
|
||||
### **Grounding Debugging & Error Handling** ✅ **COMPLETED**
|
||||
- ✅ **Removed Mock Data Fallbacks**: Eliminated all fallback mock sources that were masking real issues
|
||||
- ✅ **Enhanced Error Logging**: Added detailed logging of API response structure and grounding metadata
|
||||
- ✅ **Fail-Fast Approach**: Services now fail immediately instead of silently falling back to mock data
|
||||
- ✅ **Debug Information**: Added comprehensive logging of response attributes, types, and values
|
||||
- ✅ **Critical Error Detection**: Clear error messages when grounding chunks, supports, or metadata are missing
|
||||
|
||||
### **Frontend Grounding Data Display** ✅ **COMPLETED**
|
||||
- ✅ **GroundingDataDisplay Component**: Created comprehensive component to show research sources, citations, and quality metrics
|
||||
- ✅ **Enhanced Interfaces**: Updated TypeScript interfaces to include grounding data fields (citations, quality_metrics, grounding_enabled)
|
||||
- ✅ **Real-time Updates**: Frontend now listens for grounding data updates from CopilotKit actions
|
||||
- ✅ **Rich Data Visualization**: Displays quality scores, source credibility, citation coverage, and research source details
|
||||
- ✅ **Professional UI**: Clean, enterprise-grade interface showing AI-generated content with factual grounding
|
||||
|
||||
### **Import Error Resolution** ✅ **COMPLETED**
|
||||
- ✅ **Fixed Relative Import Errors**: Changed all relative imports to absolute imports
|
||||
- ✅ **Updated Service Import Paths**: Fixed `__init__.py` files to use correct import paths
|
||||
- ✅ **Router Import Fix**: Fixed LinkedIn router to import `LinkedInService` class and create instance
|
||||
- ✅ **Function Name Corrections**: Updated to use correct Gemini provider function names
|
||||
- ✅ **Graceful Service Initialization**: Added try-catch blocks for missing dependencies
|
||||
|
||||
### **Files Modified**
|
||||
- `backend/services/linkedin_service.py` - Fixed imports, added error handling, and **SIGNIFICANTLY REFACTORED** for maintainability
|
||||
- `backend/routers/linkedin.py` - Fixed service import, initialization, and method calls
|
||||
- `backend/services/research/__init__.py` - Fixed import paths
|
||||
- `backend/services/citation/__init__.py` - Fixed import paths
|
||||
- `backend/services/quality/__init__.py` - Fixed import paths
|
||||
- `backend/services/llm_providers/__init__.py` - Fixed import paths and function names
|
||||
- `backend/services/linkedin/quality_handler.py` - **NEW**: Extracted quality metrics handling to separate module
|
||||
- `backend/services/linkedin/content_generator.py` - **NEW**: Extracted large content generation methods (posts & articles)
|
||||
- `backend/services/linkedin/research_handler.py` - **NEW**: Extracted research logic and timing handling
|
||||
- `backend/services/linkedin/__init__.py` - **NEW**: Package initialization for linkedin services
|
||||
- `backend/services/citation/citation_manager.py` - **FIXED**: Updated to handle ResearchSource Pydantic models
|
||||
- `backend/services/quality/content_analyzer.py` - **FIXED**: Updated to work with ResearchSource objects
|
||||
- `backend/services/llm_providers/gemini_grounded_provider.py` - **FIXED**: Removed mock data fallbacks, enhanced error handling and debugging
|
||||
- `frontend/src/services/linkedInWriterApi.ts` - **ENHANCED**: Added grounding data interfaces (citations, quality_metrics, grounding_enabled)
|
||||
- `frontend/src/components/LinkedInWriter/components/GroundingDataDisplay.tsx` - **NEW**: Component to display research sources, citations, and quality metrics
|
||||
- `frontend/src/components/LinkedInWriter/components/ContentEditor.tsx` - **ENHANCED**: Integrated grounding data display
|
||||
- `frontend/src/components/LinkedInWriter/hooks/useLinkedInWriter.ts` - **ENHANCED**: Added grounding data state management
|
||||
- `frontend/src/components/LinkedInWriter/RegisterLinkedInActions.tsx` - **ENHANCED**: Updated to extract and pass grounding data
|
||||
- `backend/test_imports.py` - Created comprehensive import test script
|
||||
- `backend/test_linkedin_service.py` - Created service functionality test script
|
||||
- `backend/test_request_validation.py` - Created request validation test script
|
||||
- `frontend/src/services/linkedInWriterApi.ts` - Added missing grounding fields to request interfaces
|
||||
- `frontend/src/components/LinkedInWriter/RegisterLinkedInActions.tsx` - Updated actions to send required grounding fields
|
||||
|
||||
## 🧪 **Testing & Validation**
|
||||
|
||||
### **Integration Testing** ✅ **COMPLETED**
|
||||
- ✅ **Test Script**: `backend/test_grounding_integration.py`
|
||||
- ✅ **Service Initialization**: All new services initialize correctly
|
||||
- ✅ **Content Generation**: Grounded content generation works
|
||||
- ✅ **Citation System**: Citations are properly generated and formatted
|
||||
- ✅ **Quality Analysis**: Content quality metrics are calculated
|
||||
- ✅ **Fallback Systems**: Graceful degradation when grounding fails
|
||||
|
||||
### **Test Coverage**
|
||||
- ✅ **Individual Services**: Each service component tested independently
|
||||
- ✅ **Integration Flow**: Complete content generation pipeline tested
|
||||
- ✅ **Error Handling**: Fallback mechanisms and error scenarios tested
|
||||
- ✅ **Performance**: Response times and resource usage monitored
|
||||
- ✅ **API Integration**: Google Search and Gemini API integration tested
|
||||
|
||||
### **Next Testing Steps**
|
||||
- ✅ **Import Issues Resolved**: All import errors fixed and services working
|
||||
- ✅ **Service Initialization**: All services initialize successfully with graceful fallbacks
|
||||
- ✅ **Basic Functionality**: LinkedIn post generation working correctly
|
||||
- ✅ **Core Grounding Components**: Provider initialization, prompt building, and content processing verified
|
||||
- ✅ **Router Method Calls Fixed**: All LinkedIn service method calls corrected
|
||||
- ✅ **Backend Startup**: Backend imports and starts successfully
|
||||
- ✅ **Service Integration**: LinkedIn service integration working correctly
|
||||
- ✅ **Request Validation Fixed**: Frontend now sends required grounding fields
|
||||
- ✅ **Pydantic Model Validation**: Request validation working correctly
|
||||
- 🔄 **API Integration Testing**: Test with different API keys and rate limits
|
||||
- 🔄 **Content Generation Testing**: Verify actual content generation with grounding
|
||||
- 🔄 **User Acceptance Testing**: Real user scenarios and feedback
|
||||
- 🔄 **Performance Testing**: Load testing and optimization
|
||||
- 🔄 **Security Testing**: API key management and data security
|
||||
- 🔄 **Compliance Testing**: Industry standards and regulations
|
||||
- 🔄 **End-to-End Testing**: Complete user workflow validation
|
||||
|
||||
## 🚀 **Next Implementation Steps**
|
||||
|
||||
### **Week 1: API Integration & Testing** 🔄 **IMMEDIATE PRIORITY**
|
||||
|
||||
#### **1. API Key Management & Testing**
|
||||
- **Test with different API keys**: Verify grounding works with various API configurations
|
||||
- **Rate limit handling**: Implement proper retry logic and rate limit management
|
||||
- **API quota monitoring**: Track usage and implement cost controls
|
||||
- **Fallback mechanisms**: Ensure graceful degradation when API is unavailable
|
||||
|
||||
#### **2. Content Generation Verification**
|
||||
- **Test actual content generation**: Verify that grounded content is being generated
|
||||
- **Source extraction testing**: Ensure sources are properly extracted from grounding metadata
|
||||
- **Citation generation**: Test inline citation formatting and source attribution
|
||||
- **Quality metrics**: Verify content quality assessment is working
|
||||
|
||||
#### **3. Integration Testing**
|
||||
- **End-to-end workflow**: Test complete LinkedIn content generation pipeline
|
||||
- **Error handling**: Verify all error scenarios are handled gracefully
|
||||
- **Performance testing**: Measure response times and optimize where needed
|
||||
- **User acceptance testing**: Test with real user scenarios
|
||||
|
||||
### **Week 2: Phase 2 - URL Context Integration** 📋 **NEXT PHASE**
|
||||
|
||||
#### **1. URL Context Service Implementation**
|
||||
- **Create URL context service**: `backend/services/url_context/url_context_service.py`
|
||||
- **Google AI URL context tool**: Integrate with `url_context` tool from Google AI
|
||||
- **URL validation**: Implement proper URL validation and content extraction
|
||||
- **Source categorization**: Build system to categorize and tag sources
|
||||
|
||||
#### **2. Enhanced Source Management**
|
||||
- **Industry report library**: Curated collection of authoritative sources
|
||||
- **Competitor analysis**: Industry benchmarking and insights
|
||||
- **Source credibility scoring**: AI-powered source assessment
|
||||
- **User source input**: Allow users to provide custom URLs
|
||||
|
||||
#### **3. Advanced Features**
|
||||
- **Multi-language support**: International industry insights
|
||||
- **Custom source integration**: User-defined source libraries
|
||||
- **Quality dashboard**: Real-time content quality monitoring
|
||||
- **Performance analytics**: Track content quality and user satisfaction
|
||||
|
||||
### **Week 3: Production Deployment** 📋 **FUTURE PHASE**
|
||||
|
||||
#### **1. Production Readiness**
|
||||
- **Security hardening**: API key management and data security
|
||||
- **Performance optimization**: Caching, rate limiting, and response optimization
|
||||
- **Monitoring & alerting**: Real-time system monitoring and error tracking
|
||||
- **Documentation**: Complete API documentation and user guides
|
||||
|
||||
#### **2. User Experience**
|
||||
- **UI/UX improvements**: Enhanced grounding level selection interface
|
||||
- **Source preview**: Allow users to preview sources before generation
|
||||
- **Citation management**: User-friendly citation editing and management
|
||||
- **Quality feedback**: User feedback integration for continuous improvement
|
||||
|
||||
#### **3. Business Integration**
|
||||
- **Premium features**: Enterprise-grade grounding features
|
||||
- **Analytics dashboard**: Business metrics and usage analytics
|
||||
- **Customer support**: Support tools and documentation
|
||||
- **Marketing materials**: Case studies and success stories
|
||||
|
||||
## 📚 **References & Resources**
|
||||
|
||||
### **Google AI Documentation**
|
||||
- [Google Search Grounding](https://ai.google.dev/gemini-api/docs/google-search)
|
||||
- [URL Context Integration](https://ai.google.dev/gemini-api/docs/url-context)
|
||||
- [Gemini API Reference](https://ai.google.dev/gemini-api/docs/api-reference)
|
||||
- [Google Custom Search API](https://developers.google.com/custom-search)
|
||||
|
||||
### **Industry Standards**
|
||||
- LinkedIn Content Best Practices
|
||||
- Enterprise Content Quality Standards
|
||||
- Professional Citation Guidelines
|
||||
- Industry Research Methodologies
|
||||
- Source Credibility Assessment
|
||||
|
||||
### **Technical Resources**
|
||||
- CopilotKit Integration Guides
|
||||
- Google AI API Best Practices
|
||||
- Content Quality Assessment Tools
|
||||
- Performance Optimization Techniques
|
||||
- API Rate Limiting Strategies
|
||||
|
||||
### **Implementation Resources** ✅ **CREATED**
|
||||
- ✅ **Service Documentation**: Comprehensive service implementations
|
||||
- ✅ **Test Scripts**: Integration testing and validation
|
||||
- ✅ **Code Examples**: Working implementations for all components
|
||||
- ✅ **Dependency Management**: Updated requirements and dependencies
|
||||
- ✅ **Error Handling**: Robust fallback and error management
|
||||
|
||||
---
|
||||
|
||||
## 📝 **Document Information**
|
||||
|
||||
- **Document Version**: 3.0
|
||||
- **Last Updated**: January 2025
|
||||
- **Author**: ALwrity Development Team
|
||||
- **Review Cycle**: Quarterly
|
||||
- **Next Review**: April 2025
|
||||
- **Implementation Status**: Phase 1 Completed, Phase 2 Planning
|
||||
|
||||
---
|
||||
|
||||
*This document serves as the comprehensive guide for implementing LinkedIn factual Google grounded URL content enhancement in ALwrity. Phase 1 core services have been completed and are ready for testing and deployment. All implementation decisions should reference this document for consistency and alignment with the overall strategy.*
|
||||
6
frontend/env_template.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
# Clerk Authentication
|
||||
REACT_APP_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key_here
|
||||
REACT_APP_CLERK_JWT_TEMPLATE=
|
||||
|
||||
# API Configuration
|
||||
REACT_APP_API_BASE_URL=http://localhost:8000
|
||||
24976
frontend/package-lock.json
generated
Normal file
66
frontend/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "alwrity-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "Alwrity React Frontend",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@clerk/clerk-react": "^5.46.1",
|
||||
"@copilotkit/react-core": "^1.10.6",
|
||||
"@copilotkit/react-textarea": "^1.10.6",
|
||||
"@copilotkit/react-ui": "^1.10.6",
|
||||
"@copilotkit/shared": "^1.10.3",
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.0",
|
||||
"@mui/material": "^5.15.0",
|
||||
"@tanstack/react-query": "^5.87.1",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/recharts": "^1.8.29",
|
||||
"@wix/blog": "^1.0.488",
|
||||
"@wix/sdk": "^1.17.1",
|
||||
"axios": "^1.12.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"lucide-react": "^0.543.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^3.2.0",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"analyze": "npm run build && npx source-map-explorer 'build/static/js/*.js' --html bundle-report.html",
|
||||
"analyze:size": "npm run build && npx bundlesize"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^4.9.5",
|
||||
"source-map-explorer": "^2.5.2"
|
||||
},
|
||||
"proxy": "http://localhost:8000",
|
||||
"homepage": "/"
|
||||
}
|
||||
BIN
frontend/public/ALwrity-assistive-writing.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
frontend/public/Alwrity-copilot1.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/Alwrity-copilot2.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
frontend/public/Alwrity-fact-check.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
frontend/public/AskAlwrity-min.ico
Normal file
|
After Width: | Height: | Size: 79 KiB |
162
frontend/public/BLOG_WRITER_ASSETS_GUIDE.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Blog Writer Assets Guide
|
||||
|
||||
## 📁 Folder Structure
|
||||
|
||||
```
|
||||
frontend/public/
|
||||
├── images/
|
||||
│ └── (add 24 feature images here)
|
||||
├── videos/
|
||||
│ └── (add 6 demo videos here)
|
||||
├── blog-writer-bg.png (already exists ✅)
|
||||
└── BLOG_WRITER_ASSETS_GUIDE.md (this file)
|
||||
```
|
||||
|
||||
## 🖼️ Required Images (24 total)
|
||||
|
||||
### Phase 1: Research & Strategy (4 images)
|
||||
- `images/research-google-grounding.jpg` - Screenshot/video frame showing Google Search grounding in action
|
||||
- `images/research-competitor.jpg` - Screenshot of competitor analysis results
|
||||
- `images/research-keywords.jpg` - Screenshot showing keyword analysis and clustering
|
||||
- `images/research-angles.jpg` - Screenshot of AI-generated content angle suggestions
|
||||
|
||||
### Phase 2: Intelligent Outline (4 images)
|
||||
- `images/outline-generation.jpg` - Screenshot of AI outline generation interface
|
||||
- `images/outline-grounding.jpg` - Screenshot showing source mapping and grounding scores
|
||||
- `images/outline-refine.jpg` - Screenshot of interactive outline refinement (add/remove/merge sections)
|
||||
- `images/outline-titles.jpg` - Screenshot of multiple AI-generated title options with SEO scores
|
||||
|
||||
### Phase 3: Content Generation (4 images)
|
||||
- `images/content-generation.jpg` - Screenshot of section-by-section content generation
|
||||
- `images/content-continuity.jpg` - Screenshot showing continuity analysis and flow metrics
|
||||
- `images/content-sources.jpg` - Screenshot of automatic source integration and citations
|
||||
- `images/content-medium.jpg` - Screenshot of Medium blog mode quick generation
|
||||
|
||||
### Phase 4: SEO Analysis (4 images)
|
||||
- `images/seo-scoring.jpg` - Screenshot of comprehensive SEO scoring dashboard
|
||||
- `images/seo-recommendations.jpg` - Screenshot of actionable SEO recommendations list
|
||||
- `images/seo-apply.jpg` - Screenshot of AI-powered content refinement interface
|
||||
- `images/seo-keywords.jpg` - Screenshot of keyword density heatmap and analysis
|
||||
|
||||
### Phase 5: SEO Metadata (4 images)
|
||||
- `images/metadata-comprehensive.jpg` - Screenshot of full metadata generation interface
|
||||
- `images/metadata-social.jpg` - Screenshot of Open Graph and Twitter Cards configuration
|
||||
- `images/metadata-schema.jpg` - Screenshot of structured data (Schema.org) markup
|
||||
- `images/metadata-export.jpg` - Screenshot of multi-format output options (HTML, JSON-LD, WordPress, Wix)
|
||||
|
||||
### Phase 6: Publish & Distribute (4 images)
|
||||
- `images/publish-platforms.jpg` - Screenshot of multi-platform publishing options (WordPress, Wix, Medium)
|
||||
- `images/publish-schedule.jpg` - Screenshot of content scheduling interface with calendar
|
||||
- `images/publish-versions.jpg` - Screenshot of revision management and version history
|
||||
- `images/publish-analytics.jpg` - Screenshot of post-publish analytics dashboard
|
||||
|
||||
## 🎬 Required Videos (6 total)
|
||||
|
||||
### Phase 1: Research & Strategy
|
||||
- `videos/phase1-research.mp4` - Demo video showing:
|
||||
- Keyword input and analysis
|
||||
- Google Search grounding in action
|
||||
- Competitor analysis results
|
||||
- Content angle generation
|
||||
|
||||
### Phase 2: Intelligent Outline
|
||||
- `videos/phase2-outline.mp4` - Demo video showing:
|
||||
- AI outline generation from research
|
||||
- Source mapping and grounding scores
|
||||
- Interactive refinement (add/remove sections)
|
||||
- Title generation with SEO scores
|
||||
|
||||
### Phase 3: Content Generation
|
||||
- `videos/phase3-content.mp4` - Demo video showing:
|
||||
- Section-by-section content generation
|
||||
- Continuity analysis and flow metrics
|
||||
- Source integration and citations
|
||||
- Medium blog mode
|
||||
|
||||
### Phase 4: SEO Analysis
|
||||
- `videos/phase4-seo.mp4` - Demo video showing:
|
||||
- SEO scoring dashboard
|
||||
- Actionable recommendations
|
||||
- AI-powered content refinement ("Apply Recommendations")
|
||||
- Keyword analysis
|
||||
|
||||
### Phase 5: SEO Metadata
|
||||
- `videos/phase5-metadata.mp4` - Demo video showing:
|
||||
- Comprehensive metadata generation
|
||||
- Open Graph and Twitter Cards
|
||||
- Structured data (Schema.org)
|
||||
- Multi-format export options
|
||||
|
||||
### Phase 6: Publish & Distribute
|
||||
- `videos/phase6-publish.mp4` - Demo video showing:
|
||||
- Multi-platform publishing
|
||||
- Content scheduling
|
||||
- Version management
|
||||
- Analytics integration
|
||||
|
||||
## 📝 Image Requirements
|
||||
|
||||
- **Format**: JPG/JPEG (recommended for photos) or PNG (recommended for screenshots)
|
||||
- **Resolution**:
|
||||
- Minimum: 1200x800px (3:2 aspect ratio for cards)
|
||||
- Recommended: 1920x1280px for best quality
|
||||
- **File Size**: Keep under 500KB each for fast loading
|
||||
- **Content**: Actual screenshots from the working application
|
||||
|
||||
## 🎥 Video Requirements
|
||||
|
||||
- **Format**: MP4 (H.264 codec recommended)
|
||||
- **Duration**: 30-90 seconds per phase
|
||||
- **Resolution**:
|
||||
- Minimum: 1280x720 (720p)
|
||||
- Recommended: 1920x1080 (1080p)
|
||||
- **File Size**: Optimize to keep under 10MB each if possible
|
||||
- **Content**: Screen recordings showing the actual features in action
|
||||
|
||||
## 🚀 How to Add Assets
|
||||
|
||||
1. **Create the folders** (already created with .gitkeep files):
|
||||
```bash
|
||||
# Folders are already created, just add files
|
||||
frontend/public/images/
|
||||
frontend/public/videos/
|
||||
```
|
||||
|
||||
2. **Add your images**:
|
||||
- Take screenshots or create mockups
|
||||
- Optimize for web (compress if needed)
|
||||
- Save with exact filenames listed above
|
||||
- Place in `frontend/public/images/` folder
|
||||
|
||||
3. **Add your videos**:
|
||||
- Record screen captures of each phase
|
||||
- Edit to show key features
|
||||
- Optimize file size
|
||||
- Save with exact filenames listed above
|
||||
- Place in `frontend/public/videos/` folder
|
||||
|
||||
4. **Test the integration**:
|
||||
- Run the app: `cd frontend && npm start`
|
||||
- Open Blog Writer
|
||||
- Click "🚀 ALwrity Blog Writer SuperPowers"
|
||||
- Expand each phase to see images and videos
|
||||
|
||||
## ✅ Quick Checklist
|
||||
|
||||
- [ ] Phase 1: Research images (4) + video (1)
|
||||
- [ ] Phase 2: Outline images (4) + video (1)
|
||||
- [ ] Phase 3: Content images (4) + video (1)
|
||||
- [ ] Phase 4: SEO images (4) + video (1)
|
||||
- [ ] Phase 5: Metadata images (4) + video (1)
|
||||
- [ ] Phase 6: Publish images (4) + video (1)
|
||||
- [ ] Total: 24 images + 6 videos = 30 assets
|
||||
|
||||
## 📍 Current Implementation
|
||||
|
||||
The images and videos are referenced in:
|
||||
- `frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx`
|
||||
- Each phase card shows video when expanded
|
||||
- Each feature card shows image placeholder
|
||||
|
||||
Paths are already configured to use `/images/` and `/videos/` from the public folder.
|
||||
|
||||
BIN
frontend/public/Fact-check1.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
frontend/public/alwrity_landing_bg_vortex.png
Normal file
|
After Width: | Height: | Size: 330 KiB |
BIN
frontend/public/alwrity_landing_copilot.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
frontend/public/alwrity_landing_hero_bg.png
Normal file
|
After Width: | Height: | Size: 380 KiB |
BIN
frontend/public/alwrity_landing_pg_bg.png
Normal file
|
After Width: | Height: | Size: 346 KiB |
BIN
frontend/public/alwrity_platform_experience.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
frontend/public/alwrty_research.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
frontend/public/blog-writer-bg.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/public/content_lifecycle.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
27
frontend/public/images/.gitkeep
Normal file
@@ -0,0 +1,27 @@
|
||||
# Blog Writer Phase Images
|
||||
# Add your phase images here:
|
||||
# - research-google-grounding.jpg
|
||||
# - research-competitor.jpg
|
||||
# - research-keywords.jpg
|
||||
# - research-angles.jpg
|
||||
# - outline-generation.jpg
|
||||
# - outline-grounding.jpg
|
||||
# - outline-refine.jpg
|
||||
# - outline-titles.jpg
|
||||
# - content-generation.jpg
|
||||
# - content-continuity.jpg
|
||||
# - content-sources.jpg
|
||||
# - content-medium.jpg
|
||||
# - seo-scoring.jpg
|
||||
# - seo-recommendations.jpg
|
||||
# - seo-apply.jpg
|
||||
# - seo-keywords.jpg
|
||||
# - metadata-comprehensive.jpg
|
||||
# - metadata-social.jpg
|
||||
# - metadata-schema.jpg
|
||||
# - metadata-export.jpg
|
||||
# - publish-platforms.jpg
|
||||
# - publish-schedule.jpg
|
||||
# - publish-versions.jpg
|
||||
# - publish-analytics.jpg
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
32
frontend/public/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Alwrity - AI Content Creation Platform"
|
||||
/>
|
||||
<!-- Performance optimizations -->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
|
||||
<!-- Preconnect to Google Fonts for faster font loading -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
|
||||
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />
|
||||
|
||||
<!-- Preconnect to Clerk for faster authentication -->
|
||||
<link rel="dns-prefetch" href="https://clerk.accounts.dev" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Alwrity - AI Content Creation Platform</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
15
frontend/public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "Alwrity",
|
||||
"name": "Alwrity - AI Content Creation Platform",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
frontend/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
9
frontend/public/videos/.gitkeep
Normal file
@@ -0,0 +1,9 @@
|
||||
# Blog Writer Phase Demo Videos
|
||||
# Add your demo videos here:
|
||||
# - phase1-research.mp4
|
||||
# - phase2-outline.mp4
|
||||
# - phase3-content.mp4
|
||||
# - phase4-seo.mp4
|
||||
# - phase5-metadata.mp4
|
||||
# - phase6-publish.mp4
|
||||
|
||||
BIN
frontend/public/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4
Normal file
BIN
frontend/public/videos/text-video-voiceover.mp4
Normal file
41
frontend/scripts/analyze-bundle.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
mode: 'production',
|
||||
entry: './src/index.tsx',
|
||||
output: {
|
||||
path: path.resolve(__dirname, '../build'),
|
||||
filename: 'static/js/[name].[contenthash:8].js',
|
||||
chunkFilename: 'static/js/[name].[contenthash:8].chunk.js',
|
||||
},
|
||||
plugins: [
|
||||
new BundleAnalyzerPlugin({
|
||||
analyzerMode: 'static',
|
||||
openAnalyzer: false,
|
||||
reportFilename: '../bundle-report.html',
|
||||
}),
|
||||
],
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
cacheGroups: {
|
||||
vendor: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name: 'vendors',
|
||||
chunks: 'all',
|
||||
},
|
||||
mui: {
|
||||
test: /[\\/]node_modules[\\/]@mui[\\/]/,
|
||||
name: 'mui',
|
||||
chunks: 'all',
|
||||
},
|
||||
framer: {
|
||||
test: /[\\/]node_modules[\\/]framer-motion[\\/]/,
|
||||
name: 'framer-motion',
|
||||
chunks: 'all',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
605
frontend/src/App.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { ClerkProvider, useAuth } from '@clerk/clerk-react';
|
||||
import "@copilotkit/react-ui/styles.css";
|
||||
import Wizard from './components/OnboardingWizard/Wizard';
|
||||
import MainDashboard from './components/MainDashboard/MainDashboard';
|
||||
import SEODashboard from './components/SEODashboard/SEODashboard';
|
||||
import ContentPlanningDashboard from './components/ContentPlanningDashboard/ContentPlanningDashboard';
|
||||
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
|
||||
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||
import StoryWriter from './components/StoryWriter/StoryWriter';
|
||||
import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator';
|
||||
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
|
||||
import {
|
||||
VideoStudioDashboard,
|
||||
CreateVideo,
|
||||
AvatarVideo,
|
||||
EnhanceVideo,
|
||||
ExtendVideo,
|
||||
EditVideo,
|
||||
TransformVideo,
|
||||
SocialVideo,
|
||||
FaceSwap,
|
||||
VideoTranslate,
|
||||
VideoBackgroundRemover,
|
||||
AddAudioToVideo,
|
||||
LibraryVideo,
|
||||
} from './components/VideoStudio';
|
||||
import { ProductMarketingDashboard } from './components/ProductMarketing';
|
||||
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
|
||||
import PricingPage from './components/Pricing/PricingPage';
|
||||
import WixTestPage from './components/WixTestPage/WixTestPage';
|
||||
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
|
||||
import WordPressCallbackPage from './components/WordPressCallbackPage/WordPressCallbackPage';
|
||||
import BingCallbackPage from './components/BingCallbackPage/BingCallbackPage';
|
||||
import BingAnalyticsStorage from './components/BingAnalyticsStorage/BingAnalyticsStorage';
|
||||
import ResearchTest from './pages/ResearchTest';
|
||||
import IntentResearchTest from './pages/IntentResearchTest';
|
||||
import SchedulerDashboard from './pages/SchedulerDashboard';
|
||||
import BillingPage from './pages/BillingPage';
|
||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||
import Landing from './components/Landing/Landing';
|
||||
import ErrorBoundary from './components/shared/ErrorBoundary';
|
||||
import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest';
|
||||
import CopilotKitDegradedBanner from './components/shared/CopilotKitDegradedBanner';
|
||||
import { OnboardingProvider } from './contexts/OnboardingContext';
|
||||
import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext';
|
||||
import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext';
|
||||
import { useOAuthTokenAlerts } from './hooks/useOAuthTokenAlerts';
|
||||
|
||||
import { setAuthTokenGetter, setClerkSignOut } from './api/client';
|
||||
import { setMediaAuthTokenGetter } from './utils/fetchMediaBlobUrl';
|
||||
import { setBillingAuthTokenGetter } from './services/billingService';
|
||||
import { useOnboarding } from './contexts/OnboardingContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
|
||||
|
||||
// interface OnboardingStatus {
|
||||
// onboarding_required: boolean;
|
||||
// onboarding_complete: boolean;
|
||||
// current_step?: number;
|
||||
// total_steps?: number;
|
||||
// completion_percentage?: number;
|
||||
// }
|
||||
|
||||
// Conditional CopilotKit wrapper that only shows sidebar on content-planning route
|
||||
const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// Do not render CopilotSidebar here. Let specific pages/components control it.
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Component to handle initial routing based on subscription and onboarding status
|
||||
// Flow: Subscription → Onboarding → Dashboard
|
||||
const InitialRouteHandler: React.FC = () => {
|
||||
const { loading, error, isOnboardingComplete, initializeOnboarding, data } = useOnboarding();
|
||||
const { subscription, loading: subscriptionLoading, error: subscriptionError, checkSubscription } = useSubscription();
|
||||
// Note: subscriptionError is available for future error handling
|
||||
const [connectionError, setConnectionError] = useState<{
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}>({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Poll for OAuth token alerts and show toast notifications
|
||||
// Only enabled when user is authenticated (has subscription)
|
||||
useOAuthTokenAlerts({
|
||||
enabled: subscription?.active === true,
|
||||
interval: 60000, // Poll every 1 minute
|
||||
});
|
||||
|
||||
// Check subscription on mount (non-blocking - don't wait for it to route)
|
||||
useEffect(() => {
|
||||
// Delay subscription check slightly to allow auth token getter to be installed first
|
||||
const timeoutId = setTimeout(() => {
|
||||
checkSubscription().catch((err) => {
|
||||
console.error('Error checking subscription (non-blocking):', err);
|
||||
|
||||
// Check if it's a connection error - handle it locally
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
// Don't block routing on subscription check errors - allow graceful degradation
|
||||
});
|
||||
}, 100); // Small delay to ensure TokenInstaller has run
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []); // Remove checkSubscription dependency to prevent loop
|
||||
|
||||
// Initialize onboarding only after subscription is confirmed
|
||||
useEffect(() => {
|
||||
if (subscription && !subscriptionLoading) {
|
||||
// Check if user is new (no subscription record at all)
|
||||
const isNewUser = !subscription || subscription.plan === 'none';
|
||||
|
||||
console.log('InitialRouteHandler: Subscription data received:', {
|
||||
plan: subscription.plan,
|
||||
active: subscription.active,
|
||||
isNewUser,
|
||||
subscriptionLoading
|
||||
});
|
||||
|
||||
if (subscription.active && !isNewUser) {
|
||||
console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...');
|
||||
initializeOnboarding();
|
||||
}
|
||||
}
|
||||
}, [subscription, subscriptionLoading, initializeOnboarding]);
|
||||
|
||||
// Handle connection error - show connection error page
|
||||
if (connectionError.hasError) {
|
||||
const handleRetry = () => {
|
||||
setConnectionError({
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
// Re-trigger the subscription check using context
|
||||
checkSubscription().catch((err) => {
|
||||
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
|
||||
setConnectionError({
|
||||
hasError: true,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoHome = () => {
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionErrorPage
|
||||
onRetry={handleRetry}
|
||||
onGoHome={handleGoHome}
|
||||
message={connectionError.error?.message || "Backend service is not available. Please check if the server is running."}
|
||||
title="Connection Error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state - only wait for onboarding init, not subscription check
|
||||
// Subscription check is non-blocking and happens in background
|
||||
const waitingForOnboardingInit = loading || !data;
|
||||
if (loading || waitingForOnboardingInit) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
{subscriptionLoading ? 'Checking subscription...' : 'Preparing your workspace...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
p={3}
|
||||
>
|
||||
<Typography variant="h5" color="error" gutterBottom>
|
||||
Error
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" textAlign="center">
|
||||
{error}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Decision tree for SIGNED-IN users:
|
||||
// Priority: Subscription → Onboarding → Dashboard (as per user flow: Landing → Subscription → Onboarding → Dashboard)
|
||||
|
||||
// 1. If subscription is still loading, show loading state
|
||||
if (subscriptionLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Checking subscription...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. No subscription data yet - handle gracefully
|
||||
// If onboarding is complete, allow access to dashboard (user already went through flow)
|
||||
// If onboarding not complete, check if subscription check is still loading or failed
|
||||
if (!subscription) {
|
||||
if (isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
// Onboarding not complete and no subscription data
|
||||
// If subscription check is still loading, show loading state
|
||||
if (subscriptionLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Checking subscription...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Subscription check completed but returned null/undefined
|
||||
// This likely means no subscription - redirect to pricing
|
||||
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
|
||||
return <Navigate to="/pricing" replace />;
|
||||
}
|
||||
|
||||
// 3. Check subscription status first
|
||||
const isNewUser = !subscription || subscription.plan === 'none';
|
||||
|
||||
// No active subscription → Show modal (SubscriptionContext handles this)
|
||||
// Don't redirect immediately - let the modal show first
|
||||
// User can click "Renew Subscription" button in modal to go to pricing
|
||||
// Or click "Maybe Later" to dismiss (but they still can't use features)
|
||||
if (isNewUser || !subscription.active) {
|
||||
console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext');
|
||||
// Note: SubscriptionContext will show the modal automatically when subscription is inactive
|
||||
// We still redirect to pricing for new users, but allow existing users with expired subscriptions
|
||||
// to see the modal first. The modal has a "Renew Subscription" button that navigates to pricing.
|
||||
// For new users (no subscription at all), redirect to pricing immediately
|
||||
if (isNewUser) {
|
||||
console.log('InitialRouteHandler: New user (no subscription) → Pricing page');
|
||||
return <Navigate to="/pricing" replace />;
|
||||
}
|
||||
// For existing users with inactive subscription, show modal but don't redirect immediately
|
||||
// The modal will be shown by SubscriptionContext, and user can click "Renew Subscription"
|
||||
// Allow access to dashboard (modal will be shown and block functionality)
|
||||
console.log('InitialRouteHandler: Inactive subscription - allowing access to show modal');
|
||||
// Continue to onboarding/dashboard flow - modal will be shown by SubscriptionContext
|
||||
}
|
||||
|
||||
// 4. Has active subscription, check onboarding status
|
||||
if (!isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding');
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
// 5. Has subscription AND completed onboarding → Dashboard
|
||||
console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
};
|
||||
|
||||
// Root route that chooses Landing (signed out) or InitialRouteHandler (signed in)
|
||||
const RootRoute: React.FC = () => {
|
||||
const { isSignedIn } = useAuth();
|
||||
if (isSignedIn) {
|
||||
return <InitialRouteHandler />;
|
||||
}
|
||||
return <Landing />;
|
||||
};
|
||||
|
||||
// Installs Clerk auth token getter into axios clients and stores user_id
|
||||
// Must render under ClerkProvider
|
||||
const TokenInstaller: React.FC = () => {
|
||||
const { getToken, userId, isSignedIn, signOut } = useAuth();
|
||||
|
||||
// Store user_id in localStorage when user signs in
|
||||
useEffect(() => {
|
||||
if (isSignedIn && userId) {
|
||||
console.log('TokenInstaller: Storing user_id in localStorage:', userId);
|
||||
localStorage.setItem('user_id', userId);
|
||||
|
||||
// Trigger event to notify SubscriptionContext that user is authenticated
|
||||
window.dispatchEvent(new CustomEvent('user-authenticated', { detail: { userId } }));
|
||||
} else if (!isSignedIn) {
|
||||
// Clear user_id when signed out
|
||||
console.log('TokenInstaller: Clearing user_id from localStorage');
|
||||
localStorage.removeItem('user_id');
|
||||
}
|
||||
}, [isSignedIn, userId]);
|
||||
|
||||
// Install token getter for API calls
|
||||
useEffect(() => {
|
||||
const tokenGetter = async () => {
|
||||
try {
|
||||
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
|
||||
// If a template is provided and it's not a placeholder, request a template-specific JWT
|
||||
if (template && template !== 'your_jwt_template_name_here') {
|
||||
// @ts-ignore Clerk types allow options object
|
||||
return await getToken({ template });
|
||||
}
|
||||
return await getToken();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Set token getter for main API client
|
||||
setAuthTokenGetter(tokenGetter);
|
||||
|
||||
// Set token getter for billing API client (same function)
|
||||
setBillingAuthTokenGetter(tokenGetter);
|
||||
|
||||
// Set token getter for media blob URL fetcher (for authenticated image/video requests)
|
||||
setMediaAuthTokenGetter(tokenGetter);
|
||||
}, [getToken]);
|
||||
|
||||
// Install Clerk signOut function for handling expired tokens
|
||||
useEffect(() => {
|
||||
if (signOut) {
|
||||
setClerkSignOut(async () => {
|
||||
await signOut();
|
||||
});
|
||||
}
|
||||
}, [signOut]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
// React Hooks MUST be at the top before any conditionals
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Get CopilotKit key from localStorage or .env
|
||||
const [copilotApiKey, setCopilotApiKey] = useState(() => {
|
||||
const savedKey = localStorage.getItem('copilotkit_api_key');
|
||||
const envKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
|
||||
const key = (savedKey || envKey).trim();
|
||||
|
||||
// Validate key format if present
|
||||
if (key && !key.startsWith('ck_pub_')) {
|
||||
console.warn('CopilotKit API key format invalid - must start with ck_pub_');
|
||||
}
|
||||
|
||||
return key;
|
||||
});
|
||||
|
||||
// Initialize app - loading state will be managed by InitialRouteHandler
|
||||
useEffect(() => {
|
||||
// Remove manual health check - connection errors are handled by ErrorBoundary
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Listen for CopilotKit key updates
|
||||
useEffect(() => {
|
||||
const handleKeyUpdate = (event: CustomEvent) => {
|
||||
const newKey = event.detail?.apiKey;
|
||||
if (newKey) {
|
||||
console.log('App: CopilotKit key updated, reloading...');
|
||||
setCopilotApiKey(newKey);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
|
||||
return () => window.removeEventListener('copilotkit-key-updated', handleKeyUpdate as EventListener);
|
||||
}, []);
|
||||
|
||||
// Token installer must be inside ClerkProvider; see TokenInstaller below
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Connecting to ALwrity...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Get environment variables with fallbacks
|
||||
const clerkPublishableKey = process.env.REACT_APP_CLERK_PUBLISHABLE_KEY || '';
|
||||
|
||||
// Show error if required keys are missing
|
||||
if (!clerkPublishableKey) {
|
||||
return (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="error" variant="h6">
|
||||
Missing Clerk Publishable Key
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
Please add REACT_APP_CLERK_PUBLISHABLE_KEY to your .env file
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render app with or without CopilotKit based on whether we have a key
|
||||
const renderApp = () => {
|
||||
const appContent = (
|
||||
<Router>
|
||||
<ConditionalCopilotKit>
|
||||
<TokenInstaller />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRoute />} />
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<ErrorBoundary context="Onboarding Wizard" showDetails>
|
||||
<Wizard />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
{/* Error Boundary Testing - Development Only */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Route path="/error-test" element={<ErrorBoundaryTest />} />
|
||||
)}
|
||||
<Route path="/dashboard" element={<ProtectedRoute><MainDashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/seo-dashboard" element={<ProtectedRoute><SEODashboard /></ProtectedRoute>} />
|
||||
<Route path="/content-planning" element={<ProtectedRoute><ContentPlanningDashboard /></ProtectedRoute>} />
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
|
||||
<Route path="/youtube-creator" element={<ProtectedRoute><YouTubeCreator /></ProtectedRoute>} />
|
||||
<Route path="/podcast-maker" element={<ProtectedRoute><PodcastDashboard /></ProtectedRoute>} />
|
||||
<Route path="/image-studio" element={<ProtectedRoute><ImageStudioDashboard /></ProtectedRoute>} />
|
||||
<Route path="/video-studio" element={<ProtectedRoute><VideoStudioDashboard /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/create" element={<ProtectedRoute><CreateVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/avatar" element={<ProtectedRoute><AvatarVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/enhance" element={<ProtectedRoute><EnhanceVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/extend" element={<ProtectedRoute><ExtendVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/edit" element={<ProtectedRoute><EditVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/transform" element={<ProtectedRoute><TransformVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/social" element={<ProtectedRoute><SocialVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/face-swap" element={<ProtectedRoute><FaceSwap /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/video-translate" element={<ProtectedRoute><VideoTranslate /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/video-background-remover" element={<ProtectedRoute><VideoBackgroundRemover /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/add-audio-to-video" element={<ProtectedRoute><AddAudioToVideo /></ProtectedRoute>} />
|
||||
<Route path="/video-studio/library" element={<ProtectedRoute><LibraryVideo /></ProtectedRoute>} />
|
||||
<Route path="/image-generator" element={<ProtectedRoute><CreateStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-editor" element={<ProtectedRoute><EditStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-upscale" element={<ProtectedRoute><UpscaleStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-control" element={<ProtectedRoute><ControlStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
|
||||
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} />
|
||||
<Route path="/product-marketing" element={<Navigate to="/campaign-creator" replace />} />
|
||||
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
|
||||
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/research-test" element={<ResearchTest />} />
|
||||
<Route path="/intent-research" element={<IntentResearchTest />} />
|
||||
<Route path="/wix-test" element={<WixTestPage />} />
|
||||
<Route path="/wix-test-direct" element={<WixTestPage />} />
|
||||
<Route path="/wix/callback" element={<WixCallbackPage />} />
|
||||
<Route path="/wp/callback" element={<WordPressCallbackPage />} />
|
||||
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
|
||||
<Route path="/bing/callback" element={<BingCallbackPage />} />
|
||||
<Route path="/bing-analytics-storage" element={<ProtectedRoute><BingAnalyticsStorage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</ConditionalCopilotKit>
|
||||
</Router>
|
||||
);
|
||||
|
||||
// Only wrap with CopilotKit if we have a valid key
|
||||
if (copilotApiKey && copilotApiKey.trim()) {
|
||||
// Enhanced error handler that updates health context
|
||||
const handleCopilotKitError = (e: any) => {
|
||||
console.error("CopilotKit Error:", e);
|
||||
|
||||
// Try to get health context if available
|
||||
// We'll use a custom event to notify health context since we can't access it directly here
|
||||
const errorMessage = e?.error?.message || e?.message || 'CopilotKit error occurred';
|
||||
const errorType = errorMessage.toLowerCase();
|
||||
|
||||
// Differentiate between fatal and transient errors
|
||||
const isFatalError =
|
||||
errorType.includes('cors') ||
|
||||
errorType.includes('ssl') ||
|
||||
errorType.includes('certificate') ||
|
||||
errorType.includes('403') ||
|
||||
errorType.includes('forbidden') ||
|
||||
errorType.includes('ERR_CERT_COMMON_NAME_INVALID');
|
||||
|
||||
// Dispatch event for health context to listen to
|
||||
window.dispatchEvent(new CustomEvent('copilotkit-error', {
|
||||
detail: {
|
||||
error: e,
|
||||
errorMessage,
|
||||
isFatal: isFatalError,
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
context="CopilotKit"
|
||||
showDetails={process.env.NODE_ENV === 'development'}
|
||||
fallback={
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="warning" gutterBottom>
|
||||
Chat Unavailable
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
CopilotKit encountered an error. The app continues to work with manual controls.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<CopilotKit
|
||||
publicApiKey={copilotApiKey}
|
||||
showDevConsole={false}
|
||||
onError={handleCopilotKitError}
|
||||
>
|
||||
{appContent}
|
||||
</CopilotKit>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Return app without CopilotKit if no key available
|
||||
return appContent;
|
||||
};
|
||||
|
||||
// Determine initial health status based on whether CopilotKit key is available
|
||||
const hasCopilotKitKey = copilotApiKey && copilotApiKey.trim();
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
context="Application Root"
|
||||
showDetails={process.env.NODE_ENV === 'development'}
|
||||
onError={(error, errorInfo) => {
|
||||
// Custom error handler - send to analytics/monitoring
|
||||
console.error('Global error caught:', { error, errorInfo });
|
||||
// TODO: Send to error tracking service (Sentry, LogRocket, etc.)
|
||||
}}
|
||||
>
|
||||
<ClerkProvider publishableKey={clerkPublishableKey}>
|
||||
<SubscriptionProvider>
|
||||
<OnboardingProvider>
|
||||
<CopilotKitHealthProvider initialHealthStatus={!!hasCopilotKitKey}>
|
||||
<CopilotKitDegradedBanner />
|
||||
{renderApp()}
|
||||
</CopilotKitHealthProvider>
|
||||
</OnboardingProvider>
|
||||
</SubscriptionProvider>
|
||||
</ClerkProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
225
frontend/src/api/analytics.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Analytics API Service
|
||||
*
|
||||
* Handles communication with the backend analytics endpoints for retrieving
|
||||
* platform analytics data from connected services like GSC, Wix, and WordPress.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// Types
|
||||
export interface AnalyticsMetrics {
|
||||
total_clicks?: number;
|
||||
total_impressions?: number;
|
||||
avg_ctr?: number;
|
||||
avg_position?: number;
|
||||
total_queries?: number;
|
||||
top_queries?: Array<{
|
||||
query: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}>;
|
||||
top_pages?: Array<{
|
||||
page: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
}>;
|
||||
// Additional properties for Bing analytics
|
||||
connection_status?: string;
|
||||
connected_sites?: number;
|
||||
sites?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
Url?: string; // Bing API uses uppercase Url
|
||||
status?: string;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}>;
|
||||
connected_since?: string;
|
||||
scope?: string;
|
||||
insights?: any;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PlatformAnalytics {
|
||||
platform: string;
|
||||
metrics: AnalyticsMetrics;
|
||||
date_range: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
last_updated: string;
|
||||
status: 'success' | 'error' | 'partial';
|
||||
error_message?: string;
|
||||
// Additional properties that may be present in analytics data
|
||||
connection_status?: string;
|
||||
sites?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
Url?: string; // Bing API uses uppercase Url
|
||||
status?: string;
|
||||
[key: string]: any; // Allow additional properties
|
||||
}>;
|
||||
connected_sites?: number;
|
||||
connected_since?: string;
|
||||
scope?: string;
|
||||
insights?: any;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
total_platforms: number;
|
||||
connected_platforms: number;
|
||||
successful_data: number;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
overall_ctr: number;
|
||||
platforms: Record<string, {
|
||||
status: string;
|
||||
last_updated: string;
|
||||
metrics_count?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
success: boolean;
|
||||
data: Record<string, PlatformAnalytics>;
|
||||
summary: AnalyticsSummary;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlatformConnectionStatus {
|
||||
connected: boolean;
|
||||
sites_count: number;
|
||||
sites: Array<{
|
||||
siteUrl?: string;
|
||||
name?: string;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlatformStatusResponse {
|
||||
success: boolean;
|
||||
platforms: Record<string, PlatformConnectionStatus>;
|
||||
total_connected: number;
|
||||
}
|
||||
|
||||
class AnalyticsAPI {
|
||||
private baseUrl = '/api/analytics';
|
||||
|
||||
/**
|
||||
* Get connection status for all platforms
|
||||
*/
|
||||
async getPlatformStatus(): Promise<PlatformStatusResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/platforms`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting platform status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data from connected platforms
|
||||
*/
|
||||
async getAnalyticsData(platforms?: string[]): Promise<AnalyticsResponse> {
|
||||
try {
|
||||
let url = `${this.baseUrl}/data`;
|
||||
|
||||
if (platforms && platforms.length > 0) {
|
||||
const platformsParam = platforms.join(',');
|
||||
url += `?platforms=${encodeURIComponent(platformsParam)}`;
|
||||
}
|
||||
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting analytics data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data using POST method
|
||||
*/
|
||||
async getAnalyticsDataPost(platforms?: string[]): Promise<AnalyticsResponse> {
|
||||
try {
|
||||
const response = await apiClient.post(`${this.baseUrl}/data`, {
|
||||
platforms,
|
||||
date_range: null // Could be extended to support custom date ranges
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting analytics data (POST):', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Search Console analytics specifically
|
||||
*/
|
||||
async getGSCAnalytics(): Promise<PlatformAnalytics> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/gsc`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting GSC analytics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics summary across all platforms
|
||||
*/
|
||||
async getAnalyticsSummary(): Promise<{
|
||||
success: boolean;
|
||||
summary: AnalyticsSummary;
|
||||
platforms_connected: number;
|
||||
platforms_total: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/summary`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting analytics summary:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test endpoint - Get platform status without authentication
|
||||
*/
|
||||
async getTestPlatformStatus(): Promise<PlatformStatusResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/test/status`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting test platform status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test endpoint - Get mock analytics data without authentication
|
||||
*/
|
||||
async getTestAnalyticsData(): Promise<AnalyticsResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/test/data`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Analytics API: Error getting test analytics data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const analyticsAPI = new AnalyticsAPI();
|
||||
export default analyticsAPI;
|
||||
93
frontend/src/api/bingOAuth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Bing Webmaster OAuth API Client
|
||||
* Handles Bing Webmaster Tools OAuth2 authentication flow
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface BingOAuthStatus {
|
||||
connected: boolean;
|
||||
sites: Array<{
|
||||
id: number;
|
||||
access_token: string;
|
||||
scope: string;
|
||||
created_at: string;
|
||||
sites: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
status: string;
|
||||
}>;
|
||||
}>;
|
||||
total_sites: number;
|
||||
}
|
||||
|
||||
export interface BingOAuthResponse {
|
||||
auth_url: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface BingCallbackResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
class BingOAuthAPI {
|
||||
/**
|
||||
* Get Bing Webmaster OAuth authorization URL
|
||||
*/
|
||||
async getAuthUrl(): Promise<BingOAuthResponse> {
|
||||
try {
|
||||
console.log('BingOAuthAPI: Making GET request to /bing/auth/url');
|
||||
const response = await apiClient.get('/bing/auth/url');
|
||||
console.log('BingOAuthAPI: Response received:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('BingOAuthAPI: Error getting Bing OAuth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bing Webmaster connection status
|
||||
*/
|
||||
async getStatus(): Promise<BingOAuthStatus> {
|
||||
try {
|
||||
const response = await apiClient.get('/bing/status');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting Bing OAuth status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a Bing Webmaster site
|
||||
*/
|
||||
async disconnectSite(tokenId: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/bing/disconnect/${tokenId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting Bing site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for Bing OAuth service
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; service: string; timestamp: string; version: string }> {
|
||||
try {
|
||||
const response = await apiClient.get('/bing/health');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking Bing OAuth health:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const bingOAuthAPI = new BingOAuthAPI();
|
||||
49
frontend/src/api/businessInfo.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
console.log('🔄 Loading Business Info API client...');
|
||||
|
||||
export interface BusinessInfo {
|
||||
user_id?: number;
|
||||
business_description: string;
|
||||
industry?: string;
|
||||
target_audience?: string;
|
||||
business_goals?: string;
|
||||
}
|
||||
|
||||
export interface BusinessInfoResponse extends BusinessInfo {
|
||||
id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const businessInfoApi = {
|
||||
saveBusinessInfo: async (data: BusinessInfo): Promise<BusinessInfoResponse> => {
|
||||
console.log('API: Saving business info', data);
|
||||
const response = await apiClient.post<BusinessInfoResponse>('/onboarding/business-info', data);
|
||||
console.log('API: Business info saved successfully', response.data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getBusinessInfo: async (id: number): Promise<BusinessInfoResponse> => {
|
||||
console.log(`API: Getting business info for ID: ${id}`);
|
||||
const response = await apiClient.get<BusinessInfoResponse>(`/onboarding/business-info/${id}`);
|
||||
console.log('API: Business info retrieved successfully', response.data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getBusinessInfoByUserId: async (userId: number): Promise<BusinessInfoResponse> => {
|
||||
console.log(`API: Getting business info for user ID: ${userId}`);
|
||||
const response = await apiClient.get<BusinessInfoResponse>(`/onboarding/business-info/user/${userId}`);
|
||||
console.log('API: Business info retrieved successfully by user ID', response.data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateBusinessInfo: async (id: number, data: BusinessInfo): Promise<BusinessInfoResponse> => {
|
||||
console.log(`API: Updating business info for ID: ${id}`, data);
|
||||
const response = await apiClient.put<BusinessInfoResponse>(`/onboarding/business-info/${id}`, data);
|
||||
console.log('API: Business info updated successfully', response.data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
console.log('✅ Business Info API client loaded successfully!');
|
||||
221
frontend/src/api/cachedAnalytics.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Cached Analytics API Client
|
||||
*
|
||||
* Wraps the analytics API with intelligent caching to reduce redundant requests
|
||||
* and improve performance while managing cache invalidation.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import analyticsCache from '../services/analyticsCache';
|
||||
|
||||
interface PlatformAnalytics {
|
||||
platform: string;
|
||||
metrics: Record<string, any>;
|
||||
date_range: { start: string; end: string };
|
||||
last_updated: string;
|
||||
status: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
interface AnalyticsSummary {
|
||||
total_platforms: number;
|
||||
connected_platforms: number;
|
||||
successful_data: number;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
overall_ctr: number;
|
||||
platforms: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PlatformConnectionStatus {
|
||||
connected: boolean;
|
||||
sites_count: number;
|
||||
sites: any[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AnalyticsResponse {
|
||||
data: Record<string, PlatformAnalytics>;
|
||||
summary: AnalyticsSummary;
|
||||
status: Record<string, PlatformConnectionStatus>;
|
||||
}
|
||||
|
||||
class CachedAnalyticsAPI {
|
||||
private readonly CACHE_TTL = {
|
||||
PLATFORM_STATUS: 30 * 60 * 1000, // 30 minutes - status changes rarely
|
||||
ANALYTICS_DATA: 60 * 60 * 1000, // 60 minutes - analytics data cached for 1 hour
|
||||
USER_SITES: 120 * 60 * 1000, // 120 minutes - user sites change very rarely
|
||||
};
|
||||
|
||||
/**
|
||||
* Get platform connection status with caching
|
||||
*/
|
||||
async getPlatformStatus(): Promise<{ platforms: Record<string, PlatformConnectionStatus> }> {
|
||||
const endpoint = '/api/analytics/platforms';
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = analyticsCache.get<{ platforms: Record<string, PlatformConnectionStatus> }>(endpoint);
|
||||
if (cached) {
|
||||
console.log('📦 Analytics Cache HIT: Platform status (cached for 30 minutes)');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
console.log('🌐 Analytics API: Fetching platform status... (will cache for 30 minutes)');
|
||||
const response = await apiClient.get(endpoint);
|
||||
|
||||
// Cache the result with extended TTL
|
||||
analyticsCache.set(endpoint, undefined, response.data, this.CACHE_TTL.PLATFORM_STATUS);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data with caching
|
||||
*/
|
||||
async getAnalyticsData(platforms?: string[], bypassCache: boolean = false): Promise<AnalyticsResponse> {
|
||||
const baseParams: any = platforms ? { platforms: platforms.join(',') } : {};
|
||||
const endpoint = '/api/analytics/data';
|
||||
|
||||
// If bypassing cache, add timestamp to force fresh request
|
||||
const requestParams = bypassCache ? { ...baseParams, _t: Date.now() } : baseParams;
|
||||
|
||||
// Try to get from cache first (unless bypassing)
|
||||
if (!bypassCache) {
|
||||
const cached = analyticsCache.get<AnalyticsResponse>(endpoint, baseParams);
|
||||
if (cached) {
|
||||
console.log('📦 Analytics Cache HIT: Analytics data (cached for 60 minutes)');
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
console.log('🌐 Analytics API: Fetching analytics data... (will cache for 60 minutes)', requestParams);
|
||||
const response = await apiClient.get(endpoint, { params: requestParams });
|
||||
|
||||
// Cache the result with extended TTL (unless bypassing)
|
||||
if (!bypassCache) {
|
||||
analyticsCache.set(endpoint, baseParams, response.data, this.CACHE_TTL.ANALYTICS_DATA);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate platform status cache
|
||||
*/
|
||||
invalidatePlatformStatus(): void {
|
||||
analyticsCache.invalidate('/api/analytics/platforms');
|
||||
console.log('🔄 Analytics Cache: Platform status invalidated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate analytics data cache
|
||||
*/
|
||||
invalidateAnalyticsData(): void {
|
||||
analyticsCache.invalidate('/api/analytics/data');
|
||||
console.log('🔄 Analytics Cache: Analytics data invalidated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all analytics cache
|
||||
*/
|
||||
invalidateAll(): void {
|
||||
analyticsCache.invalidate('analytics');
|
||||
console.log('🔄 Analytics Cache: All analytics cache invalidated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh analytics data (bypass cache)
|
||||
*/
|
||||
async forceRefreshAnalyticsData(platforms?: string[]): Promise<AnalyticsResponse> {
|
||||
// Try to clear backend cache first (but don't fail if it doesn't work)
|
||||
try {
|
||||
await this.clearBackendCache(platforms);
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Backend cache clearing failed, continuing with frontend cache clear:', error);
|
||||
}
|
||||
|
||||
// Always invalidate frontend cache
|
||||
this.invalidateAnalyticsData();
|
||||
|
||||
// Finally get fresh data with cache bypass
|
||||
return this.getAnalyticsData(platforms, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear backend analytics cache
|
||||
*/
|
||||
async clearBackendCache(platforms?: string[]): Promise<void> {
|
||||
try {
|
||||
if (platforms && platforms.length > 0) {
|
||||
// Clear cache for specific platforms
|
||||
for (const platform of platforms) {
|
||||
await apiClient.post('/api/analytics/cache/clear', null, {
|
||||
params: { platform }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Clear all cache
|
||||
await apiClient.post('/api/analytics/cache/clear');
|
||||
}
|
||||
console.log('🔄 Backend analytics cache cleared');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to clear backend cache:', error);
|
||||
// Don't throw error, just log it - frontend cache clearing is more important
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh platform status (bypass cache)
|
||||
*/
|
||||
async forceRefreshPlatformStatus(): Promise<{ platforms: Record<string, PlatformConnectionStatus> }> {
|
||||
this.invalidatePlatformStatus();
|
||||
return this.getPlatformStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for debugging
|
||||
*/
|
||||
getCacheStats() {
|
||||
return analyticsCache.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache
|
||||
*/
|
||||
clearCache(): void {
|
||||
analyticsCache.invalidate();
|
||||
console.log('🗑️ Analytics Cache: All cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics data with database-first caching (most aggressive)
|
||||
* Use this when you know the data is stored in the database
|
||||
*/
|
||||
async getAnalyticsDataFromDB(platforms?: string[]): Promise<AnalyticsResponse> {
|
||||
const params = platforms ? { platforms: platforms.join(',') } : undefined;
|
||||
const endpoint = '/api/analytics/data';
|
||||
|
||||
// Try to get from cache first
|
||||
const cached = analyticsCache.get<AnalyticsResponse>(endpoint, params);
|
||||
if (cached) {
|
||||
console.log('📦 Analytics Cache HIT: Analytics data from DB (cached for 2 hours)');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
console.log('🌐 Analytics API: Fetching analytics data from DB... (will cache for 2 hours)', params);
|
||||
const response = await apiClient.get(endpoint, { params });
|
||||
|
||||
// Cache the result with database TTL (very long since it's from DB)
|
||||
analyticsCache.setDatabaseData(endpoint, params, response.data);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const cachedAnalyticsAPI = new CachedAnalyticsAPI();
|
||||
|
||||
export default cachedAnalyticsAPI;
|
||||
456
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Global subscription error handler - will be set by the app
|
||||
// Can be async to support subscription status refresh
|
||||
let globalSubscriptionErrorHandler: ((error: any) => boolean | Promise<boolean>) | null = null;
|
||||
|
||||
export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boolean | Promise<boolean>) => {
|
||||
globalSubscriptionErrorHandler = handler;
|
||||
};
|
||||
|
||||
// Export a function to trigger subscription error handler from outside axios interceptors
|
||||
export const triggerSubscriptionError = async (error: any) => {
|
||||
const status = error?.response?.status;
|
||||
console.log('triggerSubscriptionError: Received error', {
|
||||
hasHandler: !!globalSubscriptionErrorHandler,
|
||||
status,
|
||||
dataKeys: error?.response?.data ? Object.keys(error.response.data) : null
|
||||
});
|
||||
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
console.log('triggerSubscriptionError: Calling global subscription error handler');
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
// Handle both sync and async handlers
|
||||
return result instanceof Promise ? await result : result;
|
||||
}
|
||||
|
||||
console.warn('triggerSubscriptionError: No global subscription error handler registered');
|
||||
return false;
|
||||
};
|
||||
|
||||
// Optional token getter installed from within the app after Clerk is available
|
||||
let authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
|
||||
// Optional Clerk sign-out function - set by App.tsx when Clerk is available
|
||||
let clerkSignOut: (() => Promise<void>) | null = null;
|
||||
|
||||
export const setClerkSignOut = (signOutFn: () => Promise<void>) => {
|
||||
clerkSignOut = signOutFn;
|
||||
};
|
||||
|
||||
export const setAuthTokenGetter = (getter: () => Promise<string | null>) => {
|
||||
authTokenGetter = getter;
|
||||
};
|
||||
|
||||
// Get API URL from environment variables
|
||||
export const getApiUrl = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// In production, use the environment variable or fallback
|
||||
return process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL;
|
||||
}
|
||||
return ''; // Use proxy in development
|
||||
};
|
||||
|
||||
// Create a shared axios instance for all API calls
|
||||
const apiBaseUrl = getApiUrl();
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 60000, // Increased to 60 seconds for regular API calls
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a specialized client for AI operations with extended timeout
|
||||
export const aiApiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 180000, // 3 minutes timeout for AI operations (matching 20-25 second responses)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a specialized client for long-running operations like SEO analysis
|
||||
export const longRunningApiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 300000, // 5 minutes timeout for SEO analysis
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create a specialized client for polling operations with reasonable timeout
|
||||
export const pollingApiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
timeout: 60000, // 60 seconds timeout for polling status checks
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor for logging (optional)
|
||||
apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
if (!authTokenGetter) {
|
||||
console.warn(`[apiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`);
|
||||
console.warn(`[apiClient] This usually means TokenInstaller hasn't run yet. Request will likely fail with 401.`);
|
||||
} else {
|
||||
try {
|
||||
const token = await authTokenGetter();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
console.log(`[apiClient] ✅ Added auth token to request: ${config.url}`);
|
||||
} else {
|
||||
console.warn(`[apiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`);
|
||||
console.warn(`[apiClient] User ID from localStorage: ${localStorage.getItem('user_id') || 'none'}`);
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error(`[apiClient] ❌ Error getting auth token for ${config.url}:`, tokenError);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[apiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e);
|
||||
// non-fatal - let the request proceed, backend will return 401 if needed
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Custom error types for better error handling
|
||||
export class ConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NetworkError';
|
||||
}
|
||||
}
|
||||
|
||||
// Add response interceptor with automatic token refresh on 401
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle network errors and timeouts (backend not available)
|
||||
if (!error.response) {
|
||||
// Network error, timeout, or backend not reachable
|
||||
const connectionError = new NetworkError(
|
||||
'Unable to connect to the backend server. Please check if the server is running.'
|
||||
);
|
||||
console.error('Network/Connection Error:', error.message || error);
|
||||
return Promise.reject(connectionError);
|
||||
}
|
||||
|
||||
// Handle server errors (5xx)
|
||||
if (error.response.status >= 500) {
|
||||
const connectionError = new ConnectionError(
|
||||
'Backend server is experiencing issues. Please try again later.'
|
||||
);
|
||||
console.error('Server Error:', error.response.status, error.response.data);
|
||||
return Promise.reject(connectionError);
|
||||
}
|
||||
|
||||
// If 401 and we haven't retried yet, try to refresh token and retry
|
||||
if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// Get fresh token
|
||||
const newToken = await authTokenGetter();
|
||||
if (newToken) {
|
||||
// Update the request with new token
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
// Retry the request
|
||||
return apiClient(originalRequest);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
// If retry failed, token is expired - sign out user and redirect to sign in
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization - allow InitialRouteHandler to work
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Token expired - sign out user and redirect to landing/sign-in
|
||||
console.warn('401 Unauthorized - token expired, signing out user');
|
||||
|
||||
// Clear any cached auth data
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
// Use Clerk signOut if available, otherwise just redirect
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => {
|
||||
// Redirect to landing page after sign out
|
||||
window.location.assign('/');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error during Clerk sign out:', err);
|
||||
// Fallback: redirect anyway
|
||||
window.location.assign('/');
|
||||
});
|
||||
} else {
|
||||
// Fallback: redirect to landing (will show sign-in if Clerk handles it)
|
||||
window.location.assign('/');
|
||||
}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 401 errors that weren't retried (e.g., no authTokenGetter, already retried, etc.)
|
||||
if (error?.response?.status === 401 && (originalRequest._retry || !authTokenGetter)) {
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Token expired - sign out user and redirect
|
||||
console.warn('401 Unauthorized - token expired (not retried), signing out user');
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => window.location.assign('/'))
|
||||
.catch(() => window.location.assign('/'));
|
||||
} else {
|
||||
window.location.assign('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add interceptors for AI client
|
||||
aiApiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making AI ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
if (!authTokenGetter) {
|
||||
console.warn(`[aiApiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`);
|
||||
} else {
|
||||
try {
|
||||
const token = await authTokenGetter();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
console.log(`[aiApiClient] ✅ Added auth token to request: ${config.url}`);
|
||||
} else {
|
||||
console.warn(`[aiApiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`);
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error(`[aiApiClient] ❌ Error getting auth token for ${config.url}:`, tokenError);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[aiApiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
aiApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// If 401 and we haven't retried yet, try to refresh token and retry
|
||||
if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const newToken = await authTokenGetter();
|
||||
if (newToken) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
return aiApiClient(originalRequest);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Token expired - sign out user and redirect
|
||||
console.warn('401 Unauthorized - token expired, signing out user');
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => window.location.assign('/'))
|
||||
.catch(() => window.location.assign('/'));
|
||||
} else {
|
||||
window.location.assign('/');
|
||||
}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('AI API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('AI API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('AI API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add interceptors for long-running client
|
||||
longRunningApiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making long-running ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
longRunningApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow or root route
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized during initialization - token may need refresh (not redirecting)');
|
||||
}
|
||||
}
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('Long-running API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('Long-running API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Long-running API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add interceptors for polling client
|
||||
pollingApiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making polling ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
pollingApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow or root route
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized during initialization - token may need refresh (not redirecting)');
|
||||
}
|
||||
}
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (!wasHandled) {
|
||||
console.warn('Polling API Client: Subscription error not handled by global handler');
|
||||
}
|
||||
// Always reject so the polling hook can also handle it
|
||||
return Promise.reject(error);
|
||||
} else {
|
||||
console.warn('Polling API Client: No global subscription error handler registered');
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Polling API Error:', error.response?.status, error.response?.data);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
177
frontend/src/api/componentLogic.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Component Logic API integration
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
// AI Research Interfaces
|
||||
export interface UserInfoRequest {
|
||||
full_name: string;
|
||||
email: string;
|
||||
company: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface UserInfoResponse {
|
||||
valid: boolean;
|
||||
user_info?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ResearchPreferencesRequest {
|
||||
research_depth: string;
|
||||
content_types: string[];
|
||||
auto_research: boolean;
|
||||
factual_content: boolean;
|
||||
}
|
||||
|
||||
export interface ResearchPreferencesResponse {
|
||||
valid: boolean;
|
||||
preferences?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ResearchRequest {
|
||||
topic: string;
|
||||
preferences: ResearchPreferencesRequest;
|
||||
}
|
||||
|
||||
export interface ResearchResponse {
|
||||
success: boolean;
|
||||
topic: string;
|
||||
results?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Personalization Interfaces
|
||||
export interface ContentStyleRequest {
|
||||
writing_style: string;
|
||||
tone: string;
|
||||
content_length: string;
|
||||
}
|
||||
|
||||
export interface ContentStyleResponse {
|
||||
valid: boolean;
|
||||
style_config?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface BrandVoiceRequest {
|
||||
personality_traits: string[];
|
||||
voice_description?: string;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
export interface BrandVoiceResponse {
|
||||
valid: boolean;
|
||||
brand_config?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AdvancedSettingsRequest {
|
||||
seo_optimization: boolean;
|
||||
readability_level: string;
|
||||
content_structure: string[];
|
||||
}
|
||||
|
||||
export interface PersonalizationSettingsRequest {
|
||||
content_style: ContentStyleRequest;
|
||||
brand_voice: BrandVoiceRequest;
|
||||
advanced_settings: AdvancedSettingsRequest;
|
||||
}
|
||||
|
||||
export interface PersonalizationSettingsResponse {
|
||||
valid: boolean;
|
||||
settings?: any;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// Research Utilities Interfaces
|
||||
export interface ResearchTopicRequest {
|
||||
topic: string;
|
||||
api_keys: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ResearchResultResponse {
|
||||
success: boolean;
|
||||
topic: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
// AI Research API Functions
|
||||
export async function validateUserInfo(request: UserInfoRequest): Promise<UserInfoResponse> {
|
||||
const res: AxiosResponse<UserInfoResponse> = await apiClient.post('/api/onboarding/ai-research/validate-user', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function configureResearchPreferences(request: ResearchPreferencesRequest): Promise<ResearchPreferencesResponse> {
|
||||
const res: AxiosResponse<ResearchPreferencesResponse> = await apiClient.post('/api/onboarding/ai-research/configure-preferences', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processResearchRequest(request: ResearchRequest): Promise<ResearchResponse> {
|
||||
const res: AxiosResponse<ResearchResponse> = await apiClient.post('/api/onboarding/ai-research/process-research', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchConfigurationOptions(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/ai-research/configuration-options');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchPreferences(): Promise<ResearchPreferencesResponse> {
|
||||
const res: AxiosResponse<ResearchPreferencesResponse> = await apiClient.get('/api/onboarding/ai-research/preferences');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Personalization API Functions
|
||||
export async function validateContentStyle(request: ContentStyleRequest): Promise<ContentStyleResponse> {
|
||||
const res: AxiosResponse<ContentStyleResponse> = await apiClient.post('/api/onboarding/personalization/validate-style', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function configureBrandVoice(request: BrandVoiceRequest): Promise<BrandVoiceResponse> {
|
||||
const res: AxiosResponse<BrandVoiceResponse> = await apiClient.post('/api/onboarding/personalization/configure-brand', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processPersonalizationSettings(request: PersonalizationSettingsRequest): Promise<PersonalizationSettingsResponse> {
|
||||
const res: AxiosResponse<PersonalizationSettingsResponse> = await apiClient.post('/api/onboarding/personalization/process-settings', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getPersonalizationConfigurationOptions(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/personalization/configuration-options');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function generateContentGuidelines(settings: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/personalization/generate-guidelines', settings);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// Research Utilities API Functions
|
||||
export async function processResearchTopic(request: ResearchTopicRequest): Promise<ResearchResultResponse> {
|
||||
const res: AxiosResponse<ResearchResultResponse> = await apiClient.post('/api/onboarding/research/process-topic', request);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function processResearchResults(results: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/process-results', results);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function validateResearchRequest(topic: string, api_keys: Record<string, string>): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/validate-request', { topic, api_keys });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchProvidersInfo(): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/research/providers-info');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function generateResearchReport(results: any): Promise<any> {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/research/generate-report', results);
|
||||
return res.data;
|
||||
}
|
||||
202
frontend/src/api/gsc.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/** Google Search Console API client for ALwrity frontend. */
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface GSCSite {
|
||||
siteUrl: string;
|
||||
permissionLevel: string;
|
||||
}
|
||||
|
||||
export interface GSCAnalyticsRequest {
|
||||
site_url: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
export interface GSCAnalyticsResponse {
|
||||
rows: Array<{
|
||||
keys: string[];
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
}>;
|
||||
rowCount: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
export interface GSCSitemap {
|
||||
path: string;
|
||||
lastSubmitted: string;
|
||||
contents: Array<{
|
||||
type: string;
|
||||
submitted: string;
|
||||
indexed: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface GSCStatusResponse {
|
||||
connected: boolean;
|
||||
sites?: GSCSite[];
|
||||
last_sync?: string;
|
||||
}
|
||||
|
||||
class GSCAPI {
|
||||
private baseUrl = '/gsc';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
* Set the auth token getter function
|
||||
*/
|
||||
setAuthTokenGetter(getToken: () => Promise<string | null>) {
|
||||
this.getAuthToken = getToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated API client with auth token
|
||||
*/
|
||||
private async getAuthenticatedClient() {
|
||||
const token = this.getAuthToken ? await this.getAuthToken() : null;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
return apiClient.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Google Search Console OAuth authorization URL
|
||||
*/
|
||||
async getAuthUrl(): Promise<{ auth_url: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/auth/url`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting OAuth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback (typically called from popup)
|
||||
*/
|
||||
async handleCallback(code: string, state: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/callback`, {
|
||||
params: { code, state }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error handling OAuth callback:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's Google Search Console sites
|
||||
*/
|
||||
async getSites(): Promise<{ sites: GSCSite[] }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/sites`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting sites:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search analytics data
|
||||
*/
|
||||
async getAnalytics(request: GSCAnalyticsRequest): Promise<GSCAnalyticsResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/analytics`, request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting analytics:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sitemaps for a specific site
|
||||
*/
|
||||
async getSitemaps(siteUrl: string): Promise<{ sitemaps: GSCSitemap[] }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/sitemaps/${encodeURIComponent(siteUrl)}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting sitemaps:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GSC connection status
|
||||
*/
|
||||
async getStatus(): Promise<GSCStatusResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error getting status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear incomplete GSC credentials
|
||||
*/
|
||||
async clearIncomplete(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/clear-incomplete`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error clearing incomplete credentials:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect GSC account
|
||||
*/
|
||||
async disconnect(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.delete(`${this.baseUrl}/disconnect`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Error disconnecting account:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; service: string; timestamp: string }> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/health`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('GSC API: Health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gscAPI = new GSCAPI();
|
||||
211
frontend/src/api/intentResearchApi.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Intent-Driven Research API Client
|
||||
*
|
||||
* Client for the new intent-driven research endpoints:
|
||||
* - /api/research/intent/analyze - Analyze user intent
|
||||
* - /api/research/intent/research - Execute intent-driven research
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import {
|
||||
AnalyzeIntentRequest,
|
||||
AnalyzeIntentResponse,
|
||||
IntentDrivenResearchRequest,
|
||||
IntentDrivenResearchResponse,
|
||||
} from '../components/Research/types/intent.types';
|
||||
|
||||
/**
|
||||
* Analyze user input to understand research intent.
|
||||
*
|
||||
* Uses AI to infer:
|
||||
* - What questions need answering
|
||||
* - What deliverables user expects (statistics, quotes, case studies)
|
||||
* - What depth and focus is appropriate
|
||||
*/
|
||||
export const analyzeIntent = async (
|
||||
request: AnalyzeIntentRequest
|
||||
): Promise<AnalyzeIntentResponse> => {
|
||||
try {
|
||||
const { data } = await apiClient.post<AnalyzeIntentResponse>(
|
||||
'/api/research/intent/analyze',
|
||||
request
|
||||
);
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] analyzeIntent failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
intent: {
|
||||
primary_question: request.user_input,
|
||||
secondary_questions: [],
|
||||
purpose: 'learn',
|
||||
content_output: 'general',
|
||||
expected_deliverables: ['key_statistics'],
|
||||
depth: 'detailed',
|
||||
focus_areas: [],
|
||||
perspective: null,
|
||||
time_sensitivity: null,
|
||||
input_type: 'keywords',
|
||||
original_input: request.user_input,
|
||||
confidence: 0.5,
|
||||
needs_clarification: true,
|
||||
clarifying_questions: [],
|
||||
},
|
||||
analysis_summary: 'Failed to analyze intent',
|
||||
suggested_queries: [],
|
||||
suggested_keywords: [],
|
||||
suggested_angles: [],
|
||||
quick_options: [],
|
||||
error_message: error.message || 'Failed to analyze intent',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute research based on user intent.
|
||||
*
|
||||
* This is the main endpoint for intent-driven research. It:
|
||||
* 1. Uses the confirmed intent (or infers from user_input)
|
||||
* 2. Generates targeted queries for each expected deliverable
|
||||
* 3. Executes research using Exa/Tavily/Google
|
||||
* 4. Analyzes results through the lens of user intent
|
||||
* 5. Returns exactly what the user needs
|
||||
*/
|
||||
export const executeIntentResearch = async (
|
||||
request: IntentDrivenResearchRequest
|
||||
): Promise<IntentDrivenResearchResponse> => {
|
||||
try {
|
||||
const { data } = await apiClient.post<IntentDrivenResearchResponse>(
|
||||
'/api/research/intent/research',
|
||||
request
|
||||
);
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] executeIntentResearch failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
primary_answer: '',
|
||||
secondary_answers: {},
|
||||
statistics: [],
|
||||
expert_quotes: [],
|
||||
case_studies: [],
|
||||
trends: [],
|
||||
comparisons: [],
|
||||
best_practices: [],
|
||||
step_by_step: [],
|
||||
pros_cons: null,
|
||||
definitions: {},
|
||||
examples: [],
|
||||
predictions: [],
|
||||
executive_summary: '',
|
||||
key_takeaways: [],
|
||||
suggested_outline: [],
|
||||
sources: [],
|
||||
confidence: 0,
|
||||
gaps_identified: [],
|
||||
follow_up_queries: [],
|
||||
intent: null,
|
||||
error_message: error.message || 'Research failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Combined function to analyze intent and execute research in one call.
|
||||
*
|
||||
* For simple use cases where user doesn't need to confirm intent.
|
||||
*/
|
||||
export const quickIntentResearch = async (
|
||||
userInput: string,
|
||||
options?: {
|
||||
usePersona?: boolean;
|
||||
useCompetitorData?: boolean;
|
||||
maxSources?: number;
|
||||
includeDomains?: string[];
|
||||
excludeDomains?: string[];
|
||||
}
|
||||
): Promise<IntentDrivenResearchResponse> => {
|
||||
try {
|
||||
// First analyze intent
|
||||
const analyzeResponse = await analyzeIntent({
|
||||
user_input: userInput,
|
||||
keywords: userInput.split(' ').filter(k => k.length > 2),
|
||||
use_persona: options?.usePersona ?? true,
|
||||
use_competitor_data: options?.useCompetitorData ?? true,
|
||||
});
|
||||
|
||||
if (!analyzeResponse.success) {
|
||||
return {
|
||||
success: false,
|
||||
primary_answer: '',
|
||||
secondary_answers: {},
|
||||
statistics: [],
|
||||
expert_quotes: [],
|
||||
case_studies: [],
|
||||
trends: [],
|
||||
comparisons: [],
|
||||
best_practices: [],
|
||||
step_by_step: [],
|
||||
pros_cons: null,
|
||||
definitions: {},
|
||||
examples: [],
|
||||
predictions: [],
|
||||
executive_summary: '',
|
||||
key_takeaways: [],
|
||||
suggested_outline: [],
|
||||
sources: [],
|
||||
confidence: 0,
|
||||
gaps_identified: [],
|
||||
follow_up_queries: [],
|
||||
intent: null,
|
||||
error_message: analyzeResponse.error_message || 'Failed to analyze intent',
|
||||
};
|
||||
}
|
||||
|
||||
// Execute research with inferred intent
|
||||
return await executeIntentResearch({
|
||||
user_input: userInput,
|
||||
confirmed_intent: analyzeResponse.intent,
|
||||
selected_queries: analyzeResponse.suggested_queries.slice(0, 5), // Top 5 queries
|
||||
max_sources: options?.maxSources ?? 10,
|
||||
include_domains: options?.includeDomains ?? [],
|
||||
exclude_domains: options?.excludeDomains ?? [],
|
||||
skip_inference: true, // We already have intent
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[intentResearchApi] quickIntentResearch failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
primary_answer: '',
|
||||
secondary_answers: {},
|
||||
statistics: [],
|
||||
expert_quotes: [],
|
||||
case_studies: [],
|
||||
trends: [],
|
||||
comparisons: [],
|
||||
best_practices: [],
|
||||
step_by_step: [],
|
||||
pros_cons: null,
|
||||
definitions: {},
|
||||
examples: [],
|
||||
predictions: [],
|
||||
executive_summary: '',
|
||||
key_takeaways: [],
|
||||
suggested_outline: [],
|
||||
sources: [],
|
||||
confidence: 0,
|
||||
gaps_identified: [],
|
||||
follow_up_queries: [],
|
||||
intent: null,
|
||||
error_message: error.message || 'Research failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const intentResearchApi = {
|
||||
analyzeIntent,
|
||||
executeIntentResearch,
|
||||
quickIntentResearch,
|
||||
};
|
||||
|
||||
export default intentResearchApi;
|
||||
181
frontend/src/api/oauthTokenMonitoring.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* OAuth Token Monitoring API Client
|
||||
* Functions for interacting with OAuth token monitoring endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface OAuthTokenStatus {
|
||||
connected: boolean;
|
||||
monitoring_task: {
|
||||
id: number | null;
|
||||
status: string;
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PlatformStatus {
|
||||
[platform: string]: OAuthTokenStatus;
|
||||
}
|
||||
|
||||
export interface OAuthTokenStatusResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user_id: string;
|
||||
platform_status: PlatformStatus;
|
||||
connected_platforms: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ManualRefreshResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
platform: string;
|
||||
status: string;
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
execution_result: {
|
||||
success: boolean;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
result_data: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
platform: string;
|
||||
execution_date: string;
|
||||
status: string;
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionLogsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
logs: ExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateTasksResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
tasks_created: number;
|
||||
tasks: Array<{
|
||||
id: number;
|
||||
platform: string;
|
||||
status: string;
|
||||
next_check: string | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth token monitoring status for all platforms
|
||||
*/
|
||||
export const getOAuthTokenStatus = async (userId: string): Promise<OAuthTokenStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<OAuthTokenStatusResponse>(`/api/oauth-tokens/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching OAuth token status:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch OAuth token status'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually trigger token refresh for a specific platform
|
||||
*/
|
||||
export const manualRefreshToken = async (
|
||||
userId: string,
|
||||
platform: string
|
||||
): Promise<ManualRefreshResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post<ManualRefreshResponse>(
|
||||
`/api/oauth-tokens/refresh/${userId}/${platform}`
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error manually refreshing token:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to refresh token'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for OAuth token monitoring
|
||||
*/
|
||||
export const getOAuthTokenExecutionLogs = async (
|
||||
userId: string,
|
||||
platform?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (platform) {
|
||||
params.platform = platform;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<ExecutionLogsResponse>(
|
||||
`/api/oauth-tokens/execution-logs/${userId}`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching execution logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch execution logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create OAuth token monitoring tasks
|
||||
*/
|
||||
export const createOAuthMonitoringTasks = async (
|
||||
userId: string,
|
||||
platforms?: string[]
|
||||
): Promise<CreateTasksResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post<CreateTasksResponse>(
|
||||
`/api/oauth-tokens/create-tasks/${userId}`,
|
||||
platforms ? { platforms } : {}
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating monitoring tasks:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to create monitoring tasks'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
214
frontend/src/api/onboarding.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// Make sure to install axios: npm install axios
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface APIKeyRequest {
|
||||
provider: string;
|
||||
api_key: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface APIKeyResponse {
|
||||
provider: string;
|
||||
api_key: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OnboardingStepResponse {
|
||||
step: number;
|
||||
data?: any;
|
||||
validation_errors?: string[];
|
||||
}
|
||||
|
||||
export interface OnboardingSessionResponse {
|
||||
id: number;
|
||||
user_id: number;
|
||||
current_step: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface OnboardingProgressResponse {
|
||||
progress: number;
|
||||
current_step: number;
|
||||
total_steps: number;
|
||||
completion_percentage: number;
|
||||
}
|
||||
|
||||
export async function startOnboarding() {
|
||||
const res: AxiosResponse<OnboardingSessionResponse> = await apiClient.post('/api/onboarding/start');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getCurrentStep() {
|
||||
// Get the current step from the onboarding status
|
||||
console.log('getCurrentStep: Calling /api/onboarding/status');
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/status');
|
||||
console.log('getCurrentStep: Backend returned:', res.data);
|
||||
return { step: res.data.current_step || 1 };
|
||||
}
|
||||
|
||||
export async function setCurrentStep(step: number, stepData?: any) {
|
||||
// Complete the current step to move to the next one
|
||||
console.log('setCurrentStep: Completing step', step, 'with data:', stepData);
|
||||
const res: AxiosResponse<OnboardingStepResponse> = await apiClient.post(`/api/onboarding/step/${step}/complete`, {
|
||||
data: stepData || {},
|
||||
validation_errors: []
|
||||
});
|
||||
console.log('setCurrentStep: Backend response:', res.data);
|
||||
return { step };
|
||||
}
|
||||
|
||||
export async function getApiKeys() {
|
||||
const maxRetries = 3;
|
||||
let lastError: any;
|
||||
|
||||
console.log('getApiKeys: Starting API call to /api/onboarding/api-keys');
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`getApiKeys: Attempt ${attempt + 1}/${maxRetries}`);
|
||||
const res: AxiosResponse<Record<string, string>> = await apiClient.get('/api/onboarding/api-keys');
|
||||
console.log('getApiKeys: API call successful');
|
||||
return res.data;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.log(`getApiKeys: Attempt ${attempt + 1} failed:`, error.response?.status, error.message);
|
||||
|
||||
// If it's a rate limit error (429), wait and retry
|
||||
if (error.response?.status === 429) {
|
||||
const retryAfter = error.response?.data?.retry_after || 60;
|
||||
const delay = Math.min(retryAfter * 1000, 5000); // Max 5 seconds
|
||||
|
||||
console.log(`getApiKeys: Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, don't retry
|
||||
console.log('getApiKeys: Non-rate-limit error, not retrying');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've exhausted all retries, throw the last error
|
||||
console.log('getApiKeys: All retries exhausted');
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function getApiKeysForOnboarding() {
|
||||
const maxRetries = 3;
|
||||
let lastError: any;
|
||||
|
||||
console.log('getApiKeysForOnboarding: Starting API call to /api/onboarding/api-keys/onboarding');
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`getApiKeysForOnboarding: Attempt ${attempt + 1}/${maxRetries}`);
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/api-keys/onboarding');
|
||||
console.log('getApiKeysForOnboarding: API call successful');
|
||||
return res.data.api_keys || {};
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.log(`getApiKeysForOnboarding: Attempt ${attempt + 1} failed:`, error.response?.status, error.message);
|
||||
|
||||
// If it's a rate limit error (429), wait and retry
|
||||
if (error.response?.status === 429) {
|
||||
const retryAfter = error.response?.data?.retry_after || 60;
|
||||
const delay = Math.min(retryAfter * 1000, 5000); // Max 5 seconds
|
||||
|
||||
console.log(`getApiKeysForOnboarding: Rate limited, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For other errors, don't retry
|
||||
console.log('getApiKeysForOnboarding: Non-rate-limit error, not retrying');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we've exhausted all retries, throw the last error
|
||||
console.log('getApiKeysForOnboarding: All retries exhausted');
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function saveApiKey(provider: string, api_key: string, description?: string) {
|
||||
const res: AxiosResponse<APIKeyResponse> = await apiClient.post('/api/onboarding/api-keys', {
|
||||
provider,
|
||||
api_key,
|
||||
description
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getProgress() {
|
||||
const res: AxiosResponse<OnboardingProgressResponse> = await apiClient.get('/api/onboarding/progress');
|
||||
return { progress: res.data.completion_percentage || 0 };
|
||||
}
|
||||
|
||||
export async function setProgress(progress: number) {
|
||||
// Progress is managed automatically by the backend
|
||||
// This function is kept for compatibility but doesn't make a backend call
|
||||
return { progress };
|
||||
}
|
||||
|
||||
// Additional functions for better integration
|
||||
export async function getOnboardingConfig() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/config');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getStepData(stepNumber: number) {
|
||||
const res: AxiosResponse<any> = await apiClient.get(`/api/onboarding/step/${stepNumber}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getStep1ApiKeysFromProgress(): Promise<{ gemini?: string; exa?: string; copilotkit?: string }> {
|
||||
try {
|
||||
const step = await getStepData(1);
|
||||
const keys = step?.data?.api_keys || {};
|
||||
return {
|
||||
gemini: keys.gemini || undefined,
|
||||
exa: keys.exa || undefined,
|
||||
copilotkit: keys.copilotkit || undefined,
|
||||
};
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function skipStep(stepNumber: number) {
|
||||
const res: AxiosResponse<any> = await apiClient.post(`/api/onboarding/step/${stepNumber}/skip`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function validateApiKeys() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/api-keys/validate');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function completeOnboarding() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/complete');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function resetOnboarding() {
|
||||
const res: AxiosResponse<any> = await apiClient.post('/api/onboarding/reset');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// New functions for FinalStep data loading
|
||||
export async function getOnboardingSummary() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/summary');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getWebsiteAnalysisData() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/website-analysis');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getResearchPreferencesData() {
|
||||
const res: AxiosResponse<any> = await apiClient.get('/api/onboarding/research-preferences');
|
||||
return res.data;
|
||||
}
|
||||
306
frontend/src/api/persona.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Persona API client for frontend
|
||||
* Handles writing persona generation and management
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface PersonaGenerationRequest {
|
||||
onboarding_session_id?: number;
|
||||
force_regenerate?: boolean;
|
||||
}
|
||||
|
||||
export interface PersonaResponse {
|
||||
persona_id: number;
|
||||
persona_name: string;
|
||||
archetype: string;
|
||||
core_belief: string;
|
||||
confidence_score: number;
|
||||
platforms: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PersonaGenerationResponse {
|
||||
success: boolean;
|
||||
persona_id?: number;
|
||||
message: string;
|
||||
confidence_score?: number;
|
||||
data_sufficiency?: number;
|
||||
platforms_generated?: string[];
|
||||
}
|
||||
|
||||
export interface PersonaReadinessResponse {
|
||||
ready: boolean;
|
||||
message: string;
|
||||
missing_steps: string[];
|
||||
data_sufficiency: number;
|
||||
recommendations?: string[];
|
||||
}
|
||||
|
||||
export interface PersonaPreviewResponse {
|
||||
preview: {
|
||||
identity: {
|
||||
persona_name: string;
|
||||
archetype: string;
|
||||
core_belief: string;
|
||||
brand_voice_description: string;
|
||||
};
|
||||
linguistic_fingerprint: any;
|
||||
tonal_range: any;
|
||||
sample_platform: {
|
||||
platform: string;
|
||||
adaptation: any;
|
||||
};
|
||||
};
|
||||
confidence_score: number;
|
||||
data_sufficiency: number;
|
||||
}
|
||||
|
||||
export interface PlatformInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
character_limit?: number;
|
||||
optimal_length?: string;
|
||||
word_count?: string;
|
||||
seo_optimized?: boolean;
|
||||
storytelling_focus?: boolean;
|
||||
subscription_focus?: boolean;
|
||||
}
|
||||
|
||||
export interface SupportedPlatformsResponse {
|
||||
platforms: PlatformInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has sufficient onboarding data for persona generation
|
||||
*/
|
||||
export const checkPersonaReadiness = async (userId: number = 1): Promise<PersonaReadinessResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/persona-readiness', {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error checking persona readiness:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to check persona readiness');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a preview of the writing persona without saving
|
||||
*/
|
||||
export const generatePersonaPreview = async (userId: number = 1): Promise<PersonaPreviewResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/persona-preview', {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error generating persona preview:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to generate persona preview');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate and save a writing persona from onboarding data
|
||||
*/
|
||||
export const generateWritingPersona = async (userId: number = 1, request: PersonaGenerationRequest = {}): Promise<PersonaGenerationResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/personas/generate', request, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error generating writing persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to generate writing persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all writing personas for a user
|
||||
* Note: user_id is extracted from Clerk JWT token, no need to pass it
|
||||
*/
|
||||
export const getUserPersonas = async (): Promise<{ personas: PersonaResponse[]; total_count: number }> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/personas/user');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting user personas:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to get user personas');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific persona
|
||||
*/
|
||||
export const getPersonaDetails = async (userId: number, personaId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/personas/${personaId}`, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting persona details:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to get persona details');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get persona adaptation for a specific platform
|
||||
* Note: user_id is extracted from Clerk JWT token, no need to pass it
|
||||
*/
|
||||
export const getPlatformPersona = async (platform: string): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/personas/platform/${platform}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting platform persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to get platform persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get list of supported platforms
|
||||
*/
|
||||
export const getSupportedPlatforms = async (): Promise<SupportedPlatformsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/personas/platforms');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting supported platforms:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to get supported platforms');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing persona
|
||||
*/
|
||||
export const updatePersona = async (userId: number, personaId: number, updateData: any): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.put(`/api/personas/${personaId}`, updateData, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error updating persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to update persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update platform-specific persona
|
||||
* Note: user_id is extracted from Clerk JWT token
|
||||
*/
|
||||
export const updatePlatformPersona = async (platform: string, updateData: any): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.put(`/api/personas/platform/${platform}`, updateData);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error updating platform persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to update platform persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a platform-specific persona from core persona
|
||||
* Note: user_id is extracted from Clerk JWT token
|
||||
*/
|
||||
export const generatePlatformPersona = async (platform: string): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.post(`/api/personas/generate-platform/${platform}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`Error generating ${platform} persona:`, error);
|
||||
throw new Error(error.response?.data?.detail || `Failed to generate ${platform} persona`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if Facebook persona exists for user
|
||||
* Note: user_id is extracted from Clerk JWT token or passed as parameter
|
||||
*/
|
||||
export const checkFacebookPersona = async (userId?: string): Promise<{
|
||||
has_persona: boolean;
|
||||
has_core_persona: boolean;
|
||||
persona: any;
|
||||
onboarding_completed: boolean;
|
||||
}> => {
|
||||
try {
|
||||
// Get user_id from parameter or localStorage
|
||||
const user_id = userId || localStorage.getItem('user_id');
|
||||
if (!user_id) {
|
||||
return {
|
||||
has_persona: false,
|
||||
has_core_persona: false,
|
||||
persona: null,
|
||||
onboarding_completed: false
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/api/personas/facebook-persona/check/${user_id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error checking Facebook persona:', error);
|
||||
// Return safe defaults on error
|
||||
return {
|
||||
has_persona: false,
|
||||
has_core_persona: false,
|
||||
persona: null,
|
||||
onboarding_completed: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a persona
|
||||
*/
|
||||
export const deletePersona = async (userId: number, personaId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/api/personas/${personaId}`, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to delete persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate content using persona replication engine
|
||||
*/
|
||||
export const generateContentWithPersona = async (
|
||||
userId: number,
|
||||
platform: string,
|
||||
contentRequest: string,
|
||||
contentType: string = 'post'
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/personas/generate-content', {
|
||||
user_id: userId,
|
||||
platform,
|
||||
content_request: contentRequest,
|
||||
content_type: contentType
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error generating content with persona:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to generate content with persona');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export hardened persona prompt for external use
|
||||
*/
|
||||
export const exportPersonaPrompt = async (userId: number, platform: string): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/personas/export/${platform}`, {
|
||||
params: { user_id: userId }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error exporting persona prompt:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to export persona prompt');
|
||||
}
|
||||
};
|
||||
158
frontend/src/api/personaApi.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Persona API Client
|
||||
* Handles communication with the persona generation backend services.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface PersonaGenerationRequest {
|
||||
onboarding_data: {
|
||||
websiteAnalysis?: any;
|
||||
competitorResearch?: any;
|
||||
sitemapAnalysis?: any;
|
||||
businessData?: any;
|
||||
};
|
||||
selected_platforms: string[];
|
||||
user_preferences?: any;
|
||||
}
|
||||
|
||||
export interface PersonaGenerationResponse {
|
||||
success: boolean;
|
||||
core_persona?: any;
|
||||
platform_personas?: Record<string, any>;
|
||||
quality_metrics?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PersonaQualityRequest {
|
||||
core_persona: any;
|
||||
platform_personas: Record<string, any>;
|
||||
user_feedback?: any;
|
||||
}
|
||||
|
||||
export interface PersonaQualityResponse {
|
||||
success: boolean;
|
||||
quality_metrics?: any;
|
||||
recommendations?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PersonaOptions {
|
||||
success: boolean;
|
||||
available_platforms: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
persona_types: string[];
|
||||
quality_metrics: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI writing personas using the sophisticated persona system.
|
||||
*/
|
||||
export const generateWritingPersonas = async (
|
||||
request: PersonaGenerationRequest
|
||||
): Promise<PersonaGenerationResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/step4/generate-personas', request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error generating personas:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || 'Failed to generate personas'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Assess the quality of generated personas.
|
||||
*/
|
||||
export const assessPersonaQuality = async (
|
||||
request: PersonaQualityRequest
|
||||
): Promise<PersonaQualityResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/step4/assess-quality', request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error assessing persona quality:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || 'Failed to assess persona quality'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Regenerate persona with different parameters.
|
||||
*/
|
||||
export const regeneratePersona = async (
|
||||
request: PersonaGenerationRequest
|
||||
): Promise<PersonaGenerationResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/step4/regenerate-persona', request);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error regenerating persona:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || 'Failed to regenerate persona'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available options for persona generation.
|
||||
*/
|
||||
export const getPersonaGenerationOptions = async (): Promise<PersonaOptions> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/step4/persona-options');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error getting persona options:', error);
|
||||
return {
|
||||
success: false,
|
||||
available_platforms: [],
|
||||
persona_types: [],
|
||||
quality_metrics: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to prepare onboarding data for persona generation.
|
||||
*/
|
||||
export const prepareOnboardingData = (stepData: any) => {
|
||||
return {
|
||||
websiteAnalysis: stepData?.analysis || null,
|
||||
competitorResearch: {
|
||||
competitors: stepData?.competitors || [],
|
||||
researchSummary: stepData?.researchSummary || null,
|
||||
socialMediaAccounts: stepData?.socialMediaAccounts || {}
|
||||
},
|
||||
sitemapAnalysis: stepData?.sitemapAnalysis || null,
|
||||
businessData: stepData?.businessData || null
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to validate persona generation request.
|
||||
*/
|
||||
export const validatePersonaRequest = (request: PersonaGenerationRequest): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!request.onboarding_data) {
|
||||
errors.push('Onboarding data is required');
|
||||
}
|
||||
|
||||
if (!request.selected_platforms || request.selected_platforms.length === 0) {
|
||||
errors.push('At least one platform must be selected');
|
||||
}
|
||||
|
||||
if (request.selected_platforms && request.selected_platforms.length > 5) {
|
||||
errors.push('Maximum 5 platforms can be selected');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
86
frontend/src/api/platformInsightsMonitoring.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Platform Insights Monitoring API Client
|
||||
* Provides typed functions for fetching platform insights (GSC/Bing) monitoring data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface PlatformInsightsTask {
|
||||
id: number;
|
||||
platform: 'gsc' | 'bing';
|
||||
site_url: string | null;
|
||||
status: 'active' | 'failed' | 'paused';
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsStatusResponse {
|
||||
success: boolean;
|
||||
user_id: string;
|
||||
gsc_tasks: PlatformInsightsTask[];
|
||||
bing_tasks: PlatformInsightsTask[];
|
||||
total_tasks: number;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
data_source: 'cached' | 'api' | 'onboarding' | 'storage' | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsLogsResponse {
|
||||
success: boolean;
|
||||
logs: PlatformInsightsExecutionLog[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform insights status for a user
|
||||
*/
|
||||
export const getPlatformInsightsStatus = async (
|
||||
userId: string
|
||||
): Promise<PlatformInsightsStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/scheduler/platform-insights/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching platform insights status:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch platform insights status');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for platform insights tasks
|
||||
*/
|
||||
export const getPlatformInsightsLogs = async (
|
||||
userId: string,
|
||||
limit: number = 10,
|
||||
taskId?: number
|
||||
): Promise<PlatformInsightsLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit };
|
||||
if (taskId) {
|
||||
params.task_id = taskId;
|
||||
}
|
||||
const response = await apiClient.get(`/api/scheduler/platform-insights/logs/${userId}`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching platform insights logs:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch platform insights logs');
|
||||
}
|
||||
};
|
||||
|
||||
359
frontend/src/api/researchConfig.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Research Configuration API
|
||||
* Fetches provider availability and persona-aware defaults
|
||||
*/
|
||||
|
||||
import { ResearchMode, ResearchProvider } from '../services/blogWriterApi';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ProviderAvailability {
|
||||
google_available: boolean;
|
||||
exa_available: boolean;
|
||||
tavily_available: boolean;
|
||||
gemini_key_status: 'configured' | 'missing';
|
||||
exa_key_status: 'configured' | 'missing';
|
||||
tavily_key_status: 'configured' | 'missing';
|
||||
}
|
||||
|
||||
export interface PersonaDefaults {
|
||||
industry?: string;
|
||||
target_audience?: string;
|
||||
suggested_domains: string[];
|
||||
suggested_exa_category?: string;
|
||||
has_research_persona?: boolean; // Phase 2: Indicates if research persona exists
|
||||
|
||||
// Phase 2: Additional fields for pre-filling advanced options
|
||||
default_research_mode?: string; // basic, comprehensive, targeted
|
||||
default_provider?: string; // exa, tavily, google
|
||||
suggested_keywords?: string[]; // For keyword suggestions
|
||||
research_angles?: string[]; // Alternative research focuses
|
||||
|
||||
// Phase 2+: Enhanced provider-specific defaults from research persona
|
||||
suggested_exa_search_type?: string; // auto, neural, keyword, fast, deep
|
||||
suggested_tavily_topic?: string; // general, news, finance
|
||||
suggested_tavily_search_depth?: string; // basic, advanced, fast, ultra-fast
|
||||
suggested_tavily_include_answer?: string; // false, basic, advanced
|
||||
suggested_tavily_time_range?: string; // day, week, month, year
|
||||
suggested_tavily_raw_content_format?: string; // false, markdown, text
|
||||
provider_recommendations?: Record<string, string>; // Use case -> provider mapping
|
||||
}
|
||||
|
||||
export interface ResearchPreset {
|
||||
name: string;
|
||||
keywords: string;
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
research_mode: ResearchMode;
|
||||
config: any; // ResearchConfig
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ResearchPersona {
|
||||
default_industry: string;
|
||||
default_target_audience: string;
|
||||
default_research_mode: ResearchMode;
|
||||
default_provider: ResearchProvider;
|
||||
suggested_keywords: string[];
|
||||
keyword_expansion_patterns: Record<string, string[]>;
|
||||
suggested_exa_domains: string[];
|
||||
suggested_exa_category?: string;
|
||||
suggested_exa_search_type?: string;
|
||||
suggested_tavily_topic?: string;
|
||||
suggested_tavily_search_depth?: string;
|
||||
suggested_tavily_include_answer?: string;
|
||||
suggested_tavily_time_range?: string;
|
||||
suggested_tavily_raw_content_format?: string;
|
||||
provider_recommendations?: Record<string, string>;
|
||||
research_angles: string[];
|
||||
query_enhancement_rules: Record<string, string>;
|
||||
recommended_presets: ResearchPreset[];
|
||||
research_preferences: Record<string, any>;
|
||||
generated_at?: string;
|
||||
confidence_score?: number;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ResearchConfigResponse {
|
||||
provider_availability: ProviderAvailability;
|
||||
persona_defaults: PersonaDefaults;
|
||||
research_persona?: ResearchPersona;
|
||||
onboarding_completed?: boolean;
|
||||
persona_scheduled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider availability status
|
||||
*/
|
||||
export const getProviderAvailability = async (): Promise<ProviderAvailability> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/research/providers/status');
|
||||
const data = response.data || {};
|
||||
return {
|
||||
google_available: !!data.google?.available,
|
||||
exa_available: !!data.exa?.available,
|
||||
tavily_available: !!data.tavily?.available,
|
||||
gemini_key_status: data.google?.available ? 'configured' : 'missing',
|
||||
exa_key_status: data.exa?.available ? 'configured' : 'missing',
|
||||
tavily_key_status: data.tavily?.available ? 'configured' : 'missing',
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error getting provider availability:', error);
|
||||
throw new Error(`Failed to get provider availability: ${error?.response?.statusText || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get persona-aware research defaults
|
||||
*/
|
||||
export const getPersonaDefaults = async (): Promise<PersonaDefaults> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/research/persona-defaults');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error getting persona defaults:', error);
|
||||
throw new Error(`Failed to get persona defaults: ${error?.response?.statusText || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Request deduplication: cache in-flight requests to prevent duplicate API calls
|
||||
let pendingConfigRequest: Promise<ResearchConfigResponse> | null = null;
|
||||
|
||||
/**
|
||||
* Get complete research configuration
|
||||
*
|
||||
* Uses request deduplication: if multiple components call this simultaneously,
|
||||
* they will share the same promise to prevent duplicate API calls.
|
||||
*
|
||||
* Fetches complete configuration including provider availability, persona defaults,
|
||||
* and research persona from the unified /api/research/config endpoint.
|
||||
*/
|
||||
export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
|
||||
// If a request is already in flight, return the same promise
|
||||
if (pendingConfigRequest) {
|
||||
console.log('[researchConfig] Reusing pending request to avoid duplicate API call');
|
||||
return pendingConfigRequest;
|
||||
}
|
||||
|
||||
// Create new request and cache it
|
||||
pendingConfigRequest = (async () => {
|
||||
try {
|
||||
// Use the unified /api/research/config endpoint which returns everything
|
||||
const response = await apiClient.get('/api/research/config');
|
||||
const config: ResearchConfigResponse = response.data;
|
||||
|
||||
console.log('[researchConfig] Config loaded:', {
|
||||
providers: {
|
||||
exa: config.provider_availability?.exa_available,
|
||||
tavily: config.provider_availability?.tavily_available,
|
||||
google: config.provider_availability?.google_available,
|
||||
},
|
||||
personaDefaults: {
|
||||
industry: config.persona_defaults?.industry,
|
||||
target_audience: config.persona_defaults?.target_audience,
|
||||
hasDomains: config.persona_defaults?.suggested_domains?.length > 0,
|
||||
hasResearchPersona: config.persona_defaults?.has_research_persona,
|
||||
},
|
||||
researchPersona: {
|
||||
exists: !!config.research_persona,
|
||||
hasPresets: !!config.research_persona?.recommended_presets?.length,
|
||||
},
|
||||
onboarding: {
|
||||
completed: config.onboarding_completed,
|
||||
personaScheduled: config.persona_scheduled,
|
||||
},
|
||||
});
|
||||
|
||||
return config;
|
||||
} catch (error: any) {
|
||||
const statusCode = error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
|
||||
|
||||
console.error('[researchConfig] Error getting research config:', {
|
||||
status: statusCode,
|
||||
message: errorMessage,
|
||||
fullError: error
|
||||
});
|
||||
|
||||
// Fallback: Try separate endpoints if unified endpoint fails
|
||||
try {
|
||||
console.log('[researchConfig] Falling back to separate endpoints');
|
||||
const [providersResp, personaDefaultsResp] = await Promise.allSettled([
|
||||
getProviderAvailability(),
|
||||
getPersonaDefaults(),
|
||||
]);
|
||||
|
||||
const providerAvailability: ProviderAvailability = providersResp.status === 'fulfilled'
|
||||
? providersResp.value
|
||||
: {
|
||||
google_available: true,
|
||||
exa_available: false,
|
||||
tavily_available: false,
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing',
|
||||
tavily_key_status: 'missing',
|
||||
};
|
||||
|
||||
const personaDefaults: PersonaDefaults = personaDefaultsResp.status === 'fulfilled'
|
||||
? personaDefaultsResp.value
|
||||
: {
|
||||
industry: 'Technology',
|
||||
target_audience: 'Professionals',
|
||||
suggested_domains: [],
|
||||
has_research_persona: false,
|
||||
};
|
||||
|
||||
return {
|
||||
provider_availability: providerAvailability,
|
||||
persona_defaults: personaDefaults,
|
||||
research_persona: undefined,
|
||||
onboarding_completed: false,
|
||||
persona_scheduled: false,
|
||||
};
|
||||
} catch (fallbackError: any) {
|
||||
// Provide more specific error messages based on status code
|
||||
if (statusCode === 500) {
|
||||
throw new Error(`Backend server error: ${errorMessage}. Please check backend logs or try again later.`);
|
||||
} else if (statusCode === 401) {
|
||||
throw new Error('Authentication required. Please sign in again.');
|
||||
} else if (statusCode === 403) {
|
||||
throw new Error('Access denied. Please check your permissions.');
|
||||
} else if (statusCode === 429) {
|
||||
throw new Error('Rate limit exceeded. Please try again later.');
|
||||
} else if (!statusCode && error?.message) {
|
||||
// Network error or other connection issue
|
||||
throw new Error(`Failed to connect to server: ${error.message}`);
|
||||
} else {
|
||||
throw new Error(`Failed to get research config: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Clear the cached request after completion (success or error)
|
||||
pendingConfigRequest = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return pendingConfigRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get or refresh research persona
|
||||
* @param forceRefresh - If true, regenerate persona even if cache is valid
|
||||
*/
|
||||
export const refreshResearchPersona = async (forceRefresh: boolean = false): Promise<ResearchPersona> => {
|
||||
try {
|
||||
const url = `/api/research/research-persona${forceRefresh ? '?force_refresh=true' : ''}`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error refreshing research persona:', error?.response?.status || error?.message);
|
||||
// Preserve the original error so subscription errors can be detected
|
||||
// The apiClient interceptor should handle 429 errors, but we preserve the error structure
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Competitor Analysis Response Interface
|
||||
*/
|
||||
export interface CompetitorAnalysisResponse {
|
||||
success: boolean;
|
||||
competitors?: Array<{
|
||||
name?: string;
|
||||
url?: string;
|
||||
domain?: string;
|
||||
description?: string;
|
||||
similarity_score?: number;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
social_media_accounts?: Record<string, string>;
|
||||
social_media_citations?: Array<{
|
||||
platform?: string;
|
||||
account?: string;
|
||||
url?: string;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
research_summary?: {
|
||||
total_competitors?: number;
|
||||
industry_insights?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
analysis_timestamp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor analysis data from onboarding
|
||||
*/
|
||||
export const getCompetitorAnalysis = async (): Promise<CompetitorAnalysisResponse> => {
|
||||
console.log('[getCompetitorAnalysis] ===== START: Fetching competitor analysis =====');
|
||||
try {
|
||||
console.log('[getCompetitorAnalysis] Making GET request to /api/research/competitor-analysis');
|
||||
const response = await apiClient.get('/api/research/competitor-analysis');
|
||||
console.log('[getCompetitorAnalysis] ✅ Response received:', {
|
||||
success: response.data?.success,
|
||||
competitorsCount: response.data?.competitors?.length || 0,
|
||||
error: response.data?.error,
|
||||
fullResponse: response.data
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const statusCode = error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.detail || error?.response?.data?.error || error?.message || 'Unknown error';
|
||||
|
||||
console.error('[getCompetitorAnalysis] ❌ ERROR:', {
|
||||
status: statusCode,
|
||||
message: errorMessage,
|
||||
fullError: error,
|
||||
responseData: error?.response?.data
|
||||
});
|
||||
|
||||
// Return error response instead of throwing
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
console.log('[getCompetitorAnalysis] Returning error response:', errorResponse);
|
||||
return errorResponse;
|
||||
} finally {
|
||||
console.log('[getCompetitorAnalysis] ===== END: Fetching competitor analysis =====');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh competitor analysis by re-running competitor discovery
|
||||
*/
|
||||
export const refreshCompetitorAnalysis = async (): Promise<CompetitorAnalysisResponse> => {
|
||||
console.log('[refreshCompetitorAnalysis] ===== START: Refreshing competitor analysis =====');
|
||||
try {
|
||||
console.log('[refreshCompetitorAnalysis] Making POST request to /api/research/competitor-analysis/refresh');
|
||||
const response = await apiClient.post('/api/research/competitor-analysis/refresh');
|
||||
console.log('[refreshCompetitorAnalysis] ✅ Response received:', {
|
||||
success: response.data?.success,
|
||||
competitorsCount: response.data?.competitors?.length || 0,
|
||||
error: response.data?.error,
|
||||
fullResponse: response.data
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const statusCode = error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.detail || error?.response?.data?.error || error?.message || 'Unknown error';
|
||||
|
||||
console.error('[refreshCompetitorAnalysis] ❌ ERROR:', {
|
||||
status: statusCode,
|
||||
message: errorMessage,
|
||||
fullError: error,
|
||||
responseData: error?.response?.data
|
||||
});
|
||||
|
||||
// Return error response instead of throwing
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
console.log('[refreshCompetitorAnalysis] Returning error response:', errorResponse);
|
||||
return errorResponse;
|
||||
} finally {
|
||||
console.log('[refreshCompetitorAnalysis] ===== END: Refreshing competitor analysis =====');
|
||||
}
|
||||
};
|
||||
305
frontend/src/api/schedulerDashboard.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Scheduler Dashboard API Client
|
||||
* Provides typed functions for fetching scheduler dashboard data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces for scheduler dashboard data
|
||||
export interface SchedulerStats {
|
||||
total_checks: number;
|
||||
tasks_found: number;
|
||||
tasks_executed: number;
|
||||
tasks_failed: number;
|
||||
tasks_skipped: number;
|
||||
last_check: string | null;
|
||||
last_update: string | null;
|
||||
active_executions: number;
|
||||
running: boolean;
|
||||
check_interval_minutes: number;
|
||||
min_check_interval_minutes: number;
|
||||
max_check_interval_minutes: number;
|
||||
intelligent_scheduling: boolean;
|
||||
active_strategies_count: number;
|
||||
last_interval_adjustment: string | null;
|
||||
registered_types: string[];
|
||||
// Cumulative/historical values from database
|
||||
cumulative_total_check_cycles: number;
|
||||
cumulative_tasks_found: number;
|
||||
cumulative_tasks_executed: number;
|
||||
cumulative_tasks_failed: number;
|
||||
}
|
||||
|
||||
export interface SchedulerJob {
|
||||
id: string;
|
||||
trigger_type: string;
|
||||
next_run_time: string | null;
|
||||
user_id: string | null;
|
||||
job_store: string;
|
||||
user_job_store: string;
|
||||
function_name?: string | null;
|
||||
platform?: string; // For OAuth token monitoring tasks and platform insights
|
||||
task_id?: number; // For OAuth token monitoring tasks, website analysis, and platform insights
|
||||
is_database_task?: boolean; // Flag to indicate DB task vs APScheduler job
|
||||
frequency?: string; // For OAuth tasks (e.g., 'Weekly')
|
||||
task_type?: string; // For website analysis tasks ('user_website' or 'competitor')
|
||||
task_category?: string; // 'website_analysis', 'platform_insights', 'oauth_token_monitoring'
|
||||
website_url?: string | null; // For website analysis tasks
|
||||
competitor_id?: number | null; // For competitor website analysis tasks
|
||||
}
|
||||
|
||||
export interface UserIsolation {
|
||||
enabled: boolean;
|
||||
current_user_id: string | null;
|
||||
}
|
||||
|
||||
export interface SchedulerDashboardData {
|
||||
stats: SchedulerStats;
|
||||
jobs: SchedulerJob[];
|
||||
job_count: number;
|
||||
recurring_jobs: number;
|
||||
one_time_jobs: number;
|
||||
user_isolation: UserIsolation;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface TaskFailurePattern {
|
||||
consecutive_failures: number;
|
||||
recent_failures: number;
|
||||
failure_reason: string;
|
||||
last_failure_time: string | null;
|
||||
error_patterns: string[];
|
||||
}
|
||||
|
||||
export interface TaskNeedingIntervention {
|
||||
task_id: number;
|
||||
task_type: string;
|
||||
user_id: string;
|
||||
platform?: string;
|
||||
website_url?: string;
|
||||
failure_pattern: TaskFailurePattern;
|
||||
failure_reason: string | null;
|
||||
last_failure: string | null;
|
||||
}
|
||||
|
||||
export interface TaskInfo {
|
||||
id: number;
|
||||
task_title: string;
|
||||
component_name: string;
|
||||
metric: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
id: number;
|
||||
task_id: number | null;
|
||||
user_id: number | string | null;
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
result_data: any;
|
||||
created_at: string;
|
||||
task?: TaskInfo;
|
||||
is_scheduler_log?: boolean; // Flag for scheduler logs vs execution logs
|
||||
event_type?: string;
|
||||
job_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionLogsResponse {
|
||||
logs: ExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
is_scheduler_logs?: boolean; // Flag to indicate if these are scheduler logs
|
||||
}
|
||||
|
||||
export interface SchedulerJobsResponse {
|
||||
jobs: SchedulerJob[];
|
||||
total_jobs: number;
|
||||
recurring_jobs: number;
|
||||
one_time_jobs: number;
|
||||
}
|
||||
|
||||
export interface SchedulerEvent {
|
||||
id: number;
|
||||
event_type: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed';
|
||||
event_date: string | null;
|
||||
check_cycle_number: number | null;
|
||||
check_interval_minutes: number | null;
|
||||
previous_interval_minutes: number | null;
|
||||
new_interval_minutes: number | null;
|
||||
tasks_found: number | null;
|
||||
tasks_executed: number | null;
|
||||
tasks_failed: number | null;
|
||||
tasks_by_type: Record<string, number> | null;
|
||||
check_duration_seconds: number | null;
|
||||
active_strategies_count: number | null;
|
||||
active_executions: number | null;
|
||||
job_id: string | null;
|
||||
job_type: string | null;
|
||||
user_id: string | null;
|
||||
event_data: any;
|
||||
error_message: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface SchedulerEventHistoryResponse {
|
||||
events: SchedulerEvent[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
date_filter?: {
|
||||
days: number;
|
||||
cutoff_date: string;
|
||||
showing_events_since: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler dashboard statistics and current state.
|
||||
*/
|
||||
export const getSchedulerDashboard = async (): Promise<SchedulerDashboardData> => {
|
||||
try {
|
||||
const response = await apiClient.get<SchedulerDashboardData>('/api/scheduler/dashboard');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler dashboard:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler dashboard'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get task execution logs from database.
|
||||
*
|
||||
* @param limit - Number of logs to return (1-500, default: 50)
|
||||
* @param offset - Pagination offset (default: 0)
|
||||
* @param status - Filter by status (success, failed, running, skipped)
|
||||
*/
|
||||
export const getExecutionLogs = async (
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
status?: 'success' | 'failed' | 'running' | 'skipped'
|
||||
): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (status) {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<ExecutionLogsResponse>('/api/scheduler/execution-logs', {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching execution logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch execution logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get detailed information about all scheduled jobs.
|
||||
*/
|
||||
export const getSchedulerJobs = async (): Promise<SchedulerJobsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<SchedulerJobsResponse>('/api/scheduler/jobs');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler jobs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler jobs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get scheduler event history from database.
|
||||
*
|
||||
* @param limit - Number of events to return (1-500, default: 5 for initial load, expand to 50 on hover)
|
||||
* @param offset - Pagination offset (default: 0)
|
||||
* @param eventType - Filter by event type (check_cycle, interval_adjustment, start, stop, etc.)
|
||||
* @param days - Number of days to look back (1-90, default: 7 days)
|
||||
*/
|
||||
export const getSchedulerEventHistory = async (
|
||||
limit: number = 5,
|
||||
offset: number = 0,
|
||||
eventType?: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed',
|
||||
days: number = 7
|
||||
): Promise<SchedulerEventHistoryResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset, days };
|
||||
if (eventType) {
|
||||
params.event_type = eventType;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<SchedulerEventHistoryResponse>('/api/scheduler/event-history', {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler event history:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler event history'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recent scheduler logs (restoration, job scheduling, etc.) formatted as execution logs.
|
||||
* These are shown in Execution Logs section when actual execution logs are not available.
|
||||
* Returns only the latest 5 logs (rolling window).
|
||||
*/
|
||||
export const getRecentSchedulerLogs = async (): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<ExecutionLogsResponse>('/api/scheduler/recent-scheduler-logs');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching recent scheduler logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch recent scheduler logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tasks that require manual intervention for a user.
|
||||
*/
|
||||
export const getTasksNeedingIntervention = async (userId: string): Promise<TaskNeedingIntervention[]> => {
|
||||
try {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
tasks: TaskNeedingIntervention[];
|
||||
count: number;
|
||||
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('Failed to fetch tasks needing intervention');
|
||||
}
|
||||
|
||||
return response.data.tasks || [];
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching tasks needing intervention:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch tasks needing intervention'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
85
frontend/src/api/seoAnalysis.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { longRunningApiClient } from './client';
|
||||
import { SEOAnalysisData } from '../components/shared/types';
|
||||
|
||||
// SEO Analysis API functions
|
||||
export const seoAnalysisAPI = {
|
||||
async analyzeURL(url: string, targetKeywords?: string[]): Promise<SEOAnalysisData | null> {
|
||||
try {
|
||||
console.log(`Starting SEO analysis for URL: ${url}`);
|
||||
console.log(`Target keywords:`, targetKeywords);
|
||||
|
||||
const requestData = {
|
||||
url,
|
||||
target_keywords: targetKeywords
|
||||
};
|
||||
console.log('Request data:', requestData);
|
||||
|
||||
const response = await longRunningApiClient.post('/api/seo-dashboard/analyze-comprehensive', requestData);
|
||||
console.log('Response received:', response);
|
||||
console.log('Response data:', response.data);
|
||||
|
||||
if (response.data.success) {
|
||||
console.log(`SEO analysis completed for ${url}`);
|
||||
console.log('Analysis result:', response.data);
|
||||
return response.data;
|
||||
} else {
|
||||
console.error('Analysis failed:', response.data.message);
|
||||
throw new Error(response.data.message || 'Analysis failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error analyzing URL:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getDetailedMetrics(url: string): Promise<any> {
|
||||
try {
|
||||
console.log(`Getting detailed metrics for URL: ${url}`);
|
||||
const response = await longRunningApiClient.get(`/api/seo-dashboard/metrics/${encodeURIComponent(url)}`);
|
||||
console.log(`Detailed metrics retrieved for ${url}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting detailed metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getAnalysisSummary(): Promise<any> {
|
||||
try {
|
||||
console.log('Getting analysis summary');
|
||||
const response = await longRunningApiClient.get('/api/seo-dashboard/summary');
|
||||
console.log('Analysis summary retrieved');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting analysis summary:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async batchAnalyzeURLs(urls: string[]): Promise<any[]> {
|
||||
try {
|
||||
console.log(`Starting batch analysis for ${urls.length} URLs`);
|
||||
const response = await longRunningApiClient.post('/api/seo-dashboard/batch-analyze', { urls });
|
||||
console.log(`Batch analysis completed for ${urls.length} URLs`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error in batch analysis:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await longRunningApiClient.get('/api/seo-dashboard/health');
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
129
frontend/src/api/seoDashboard.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface SEOHealthScore {
|
||||
score: number;
|
||||
change: number;
|
||||
trend: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface SEOMetric {
|
||||
value: number;
|
||||
change: number;
|
||||
trend: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface PlatformStatus {
|
||||
status: string;
|
||||
connected: boolean;
|
||||
last_sync?: string;
|
||||
data_points?: number;
|
||||
// Additional Bing-specific properties
|
||||
has_expired_tokens?: boolean;
|
||||
last_token_date?: string;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
export interface AIInsight {
|
||||
insight: string;
|
||||
priority: string;
|
||||
category: string;
|
||||
action_required: boolean;
|
||||
tool_path?: string;
|
||||
}
|
||||
|
||||
export interface SEODashboardData {
|
||||
health_score: SEOHealthScore;
|
||||
key_insight: string;
|
||||
priority_alert: string;
|
||||
metrics: Record<string, SEOMetric>;
|
||||
platforms: Record<string, PlatformStatus>;
|
||||
ai_insights: AIInsight[];
|
||||
last_updated: string;
|
||||
website_url?: string; // User's website URL from onboarding
|
||||
// Real data from backend
|
||||
summary?: {
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
ctr: number;
|
||||
position: number;
|
||||
};
|
||||
timeseries?: any[];
|
||||
competitor_insights?: {
|
||||
competitor_keywords: any[];
|
||||
content_gaps: any[];
|
||||
opportunity_score: number;
|
||||
};
|
||||
}
|
||||
|
||||
// SEO Dashboard API functions
|
||||
export const seoDashboardAPI = {
|
||||
// Get complete dashboard data
|
||||
async getDashboardData(): Promise<SEODashboardData> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/data');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO dashboard data:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get health score only
|
||||
async getHealthScore(): Promise<SEOHealthScore> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/health-score');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO health score:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get metrics only
|
||||
async getMetrics(): Promise<Record<string, SEOMetric>> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/metrics');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching SEO metrics:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get platform status
|
||||
async getPlatformStatus(): Promise<Record<string, PlatformStatus>> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/platforms');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching platform status:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Get AI insights
|
||||
async getAIInsights(): Promise<AIInsight[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/insights');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching AI insights:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Health check
|
||||
async healthCheck(): Promise<any> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/seo-dashboard/health');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking SEO dashboard health:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
209
frontend/src/api/styleDetection.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/** Style Detection API Integration */
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface StyleAnalysisRequest {
|
||||
content: {
|
||||
main_content: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
analysis_type?: 'comprehensive' | 'patterns';
|
||||
}
|
||||
|
||||
export interface StyleAnalysisResponse {
|
||||
success: boolean;
|
||||
analysis?: any;
|
||||
patterns?: any;
|
||||
guidelines?: any;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface WebCrawlRequest {
|
||||
url?: string;
|
||||
text_sample?: string;
|
||||
}
|
||||
|
||||
export interface WebCrawlResponse {
|
||||
success: boolean;
|
||||
content?: any;
|
||||
metrics?: any;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface StyleDetectionRequest {
|
||||
url?: string;
|
||||
text_sample?: string;
|
||||
include_patterns?: boolean;
|
||||
include_guidelines?: boolean;
|
||||
}
|
||||
|
||||
export interface StyleDetectionResponse {
|
||||
success: boolean;
|
||||
crawl_result?: any;
|
||||
style_analysis?: any;
|
||||
style_patterns?: any;
|
||||
style_guidelines?: any;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Consistent API URL pattern - no hardcoded localhost fallback
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
/**
|
||||
* Analyze content style using AI
|
||||
*/
|
||||
export const analyzeContentStyle = async (request: StyleAnalysisRequest): Promise<StyleAnalysisResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/analyze', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error analyzing content style:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Crawl website content for style analysis
|
||||
*/
|
||||
export const crawlWebsiteContent = async (request: WebCrawlRequest): Promise<WebCrawlResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/crawl', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error crawling website content:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete style detection workflow
|
||||
*/
|
||||
export const completeStyleDetection = async (request: StyleDetectionRequest): Promise<StyleDetectionResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post('/api/onboarding/style-detection/complete', request);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error in complete style detection:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get style detection configuration options
|
||||
*/
|
||||
export const getStyleDetectionConfiguration = async (): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/style-detection/configuration-options');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting style detection configuration:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate style detection request
|
||||
*/
|
||||
export const validateStyleDetectionRequest = (request: StyleDetectionRequest): { valid: boolean; errors: string[] } => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!request.url && !request.text_sample) {
|
||||
errors.push('Either URL or text sample is required');
|
||||
}
|
||||
|
||||
if (request.url && !request.url.startsWith('http')) {
|
||||
errors.push('URL must start with http:// or https://');
|
||||
}
|
||||
|
||||
if (request.text_sample && request.text_sample.length < 50) {
|
||||
errors.push('Text sample must be at least 50 characters');
|
||||
}
|
||||
|
||||
if (request.text_sample && request.text_sample.length > 10000) {
|
||||
errors.push('Text sample is too long (max 10,000 characters)');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if analysis exists for a website URL
|
||||
*/
|
||||
export const checkExistingAnalysis = async (websiteUrl: string): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/onboarding/style-detection/check-existing/${encodeURIComponent(websiteUrl)}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error checking existing analysis:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get analysis by ID
|
||||
*/
|
||||
export const getAnalysisById = async (analysisId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/onboarding/style-detection/analysis/${analysisId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting analysis by ID:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all analyses for the current session
|
||||
*/
|
||||
export const getSessionAnalyses = async (): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/onboarding/style-detection/session-analyses');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting session analyses:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an analysis
|
||||
*/
|
||||
export const deleteAnalysis = async (analysisId: number): Promise<any> => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/api/onboarding/style-detection/analysis/${analysisId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting analysis:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
};
|
||||
68
frontend/src/api/userData.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface UserData {
|
||||
website_url?: string;
|
||||
session?: {
|
||||
id: number;
|
||||
current_step: number;
|
||||
progress: number;
|
||||
started_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
website_analysis?: {
|
||||
website_url: string;
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
content_goals: string[];
|
||||
brand_voice: string;
|
||||
content_style: string;
|
||||
};
|
||||
api_keys?: Array<{
|
||||
id: number;
|
||||
provider: string;
|
||||
description?: string;
|
||||
}>;
|
||||
research_preferences?: {
|
||||
target_keywords: string[];
|
||||
competitor_urls: string[];
|
||||
content_topics: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const userDataAPI = {
|
||||
async getUserData(): Promise<UserData | null> {
|
||||
try {
|
||||
console.log('Fetching user data from backend...');
|
||||
const response = await apiClient.get('/api/user-data');
|
||||
console.log('User data received:', response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching user data:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getWebsiteURL(): Promise<string | null> {
|
||||
try {
|
||||
console.log('Fetching website URL...');
|
||||
const response = await apiClient.get('/api/user-data/website-url');
|
||||
console.log('Website URL received:', response.data);
|
||||
return response.data.website_url || null;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website URL:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getOnboardingData(): Promise<any> {
|
||||
try {
|
||||
console.log('Fetching onboarding data...');
|
||||
const response = await apiClient.get('/api/user-data/onboarding');
|
||||
console.log('Onboarding data received:', response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching onboarding data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
32
frontend/src/api/videoStudioApi.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Video Studio API Client
|
||||
*/
|
||||
|
||||
import { aiApiClient } from './client';
|
||||
|
||||
const API_BASE = '/api/video-studio';
|
||||
|
||||
export interface PromptOptimizeRequest {
|
||||
text: string;
|
||||
mode?: 'image' | 'video';
|
||||
style?: 'default' | 'artistic' | 'photographic' | 'technical' | 'anime' | 'realistic';
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export interface PromptOptimizeResponse {
|
||||
optimized_prompt: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize a prompt using WaveSpeed prompt optimizer
|
||||
*/
|
||||
export async function optimizePrompt(
|
||||
request: PromptOptimizeRequest
|
||||
): Promise<PromptOptimizeResponse> {
|
||||
const response = await aiApiClient.post<PromptOptimizeResponse>(
|
||||
`${API_BASE}/optimize-prompt`,
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
122
frontend/src/api/websiteAnalysisMonitoring.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Website Analysis Monitoring API Client
|
||||
* Provides typed functions for fetching website analysis monitoring data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface WebsiteAnalysisTask {
|
||||
id: number;
|
||||
website_url: string;
|
||||
task_type: 'user_website' | 'competitor';
|
||||
competitor_id: string | null;
|
||||
status: 'active' | 'failed' | 'paused';
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
frequency_days: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisStatusResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user_id: string;
|
||||
user_website_tasks: WebsiteAnalysisTask[];
|
||||
competitor_tasks: WebsiteAnalysisTask[];
|
||||
total_tasks: number;
|
||||
active_tasks: number;
|
||||
failed_tasks: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
website_url: string;
|
||||
task_type: 'user_website' | 'competitor';
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisLogsResponse {
|
||||
logs: WebsiteAnalysisExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface RetryWebsiteAnalysisResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
task: {
|
||||
id: number;
|
||||
website_url: string;
|
||||
status: string;
|
||||
next_check: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get website analysis status for a user
|
||||
*/
|
||||
export const getWebsiteAnalysisStatus = async (
|
||||
userId: string
|
||||
): Promise<WebsiteAnalysisStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/scheduler/website-analysis/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website analysis status:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch website analysis status');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for website analysis tasks
|
||||
*/
|
||||
export const getWebsiteAnalysisLogs = async (
|
||||
userId: string,
|
||||
limit: number = 10,
|
||||
offset: number = 0,
|
||||
taskId?: number
|
||||
): Promise<WebsiteAnalysisLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (taskId) {
|
||||
params.task_id = taskId;
|
||||
}
|
||||
const response = await apiClient.get(`/api/scheduler/website-analysis/logs/${userId}`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website analysis logs:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch website analysis logs');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually retry a failed website analysis task
|
||||
*/
|
||||
export const retryWebsiteAnalysis = async (
|
||||
taskId: number
|
||||
): Promise<RetryWebsiteAnalysisResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post(`/api/scheduler/website-analysis/retry/${taskId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error retrying website analysis:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to retry website analysis');
|
||||
}
|
||||
};
|
||||
|
||||
83
frontend/src/api/wix.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Wix API Client
|
||||
* Handles Wix connection status and operations
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface WixStatus {
|
||||
connected: boolean;
|
||||
sites: Array<{
|
||||
id: string;
|
||||
blog_url: string;
|
||||
blog_id: string;
|
||||
created_at: string;
|
||||
scope: string;
|
||||
}>;
|
||||
total_sites: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class WixAPI {
|
||||
private baseUrl = '/api/wix';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
* Set the auth token getter function
|
||||
*/
|
||||
setAuthTokenGetter(getToken: () => Promise<string | null>) {
|
||||
this.getAuthToken = getToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated API client with auth token
|
||||
*/
|
||||
private async getAuthenticatedClient() {
|
||||
const token = this.getAuthToken ? await this.getAuthToken() : null;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
return apiClient.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Wix connection status
|
||||
*/
|
||||
async getStatus(): Promise<WixStatus> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Wix API: Error getting status:', error);
|
||||
return {
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0,
|
||||
error: error.response?.data?.detail || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for Wix service
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
await client.get(`${this.baseUrl}/connection/status`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Wix API: Health check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wixAPI = new WixAPI();
|
||||
286
frontend/src/api/wordpress.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* WordPress API client for ALwrity frontend.
|
||||
* Handles WordPress site connections, content publishing, and management.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface WordPressSite {
|
||||
id: number;
|
||||
site_url: string;
|
||||
site_name: string;
|
||||
username: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WordPressSiteRequest {
|
||||
site_url: string;
|
||||
site_name: string;
|
||||
username: string;
|
||||
app_password: string;
|
||||
}
|
||||
|
||||
export interface WordPressPublishRequest {
|
||||
site_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
excerpt?: string;
|
||||
featured_image_path?: string;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
status?: 'draft' | 'publish' | 'private';
|
||||
meta_description?: string;
|
||||
}
|
||||
|
||||
export interface WordPressPublishResponse {
|
||||
success: boolean;
|
||||
post_id?: number;
|
||||
post_url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface WordPressPost {
|
||||
id: number;
|
||||
wp_post_id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
published_at?: string;
|
||||
created_at: string;
|
||||
site_name: string;
|
||||
site_url: string;
|
||||
}
|
||||
|
||||
export interface WordPressStatusResponse {
|
||||
connected: boolean;
|
||||
sites?: WordPressSite[];
|
||||
total_sites: number;
|
||||
}
|
||||
|
||||
export interface WordPressHealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
class WordPressAPI {
|
||||
private baseUrl = '/api/wordpress';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
* Set authentication token getter
|
||||
*/
|
||||
setAuthTokenGetter(getter: () => Promise<string | null>) {
|
||||
this.getAuthToken = getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated client with token
|
||||
*/
|
||||
private async getAuthenticatedClient() {
|
||||
if (this.getAuthToken) {
|
||||
const token = await this.getAuthToken();
|
||||
if (token) {
|
||||
// Create a new client instance with the auth header
|
||||
return apiClient.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WordPress connection status
|
||||
*/
|
||||
async getStatus(): Promise<WordPressStatusResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
// Handle 404 gracefully - endpoint may not exist yet
|
||||
if (error?.response?.status === 404) {
|
||||
// Return empty status instead of throwing
|
||||
return {
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0
|
||||
};
|
||||
}
|
||||
// Only log non-404 errors
|
||||
console.error('WordPress API: Error getting status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new WordPress site connection
|
||||
*/
|
||||
async addSite(siteData: WordPressSiteRequest): Promise<WordPressSite> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/sites`, siteData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error adding site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all WordPress sites for the current user
|
||||
*/
|
||||
async getSites(): Promise<WordPressSite[]> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/sites`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error getting sites:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a WordPress site
|
||||
*/
|
||||
async disconnectSite(siteId: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.delete(`${this.baseUrl}/sites/${siteId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error disconnecting site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish content to WordPress
|
||||
*/
|
||||
async publishContent(publishData: WordPressPublishRequest): Promise<WordPressPublishResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.post(`${this.baseUrl}/publish`, publishData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error publishing content:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get published posts from WordPress sites
|
||||
*/
|
||||
async getPosts(siteId?: number): Promise<WordPressPost[]> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const params = siteId ? { site_id: siteId } : {};
|
||||
const response = await client.get(`${this.baseUrl}/posts`, { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error getting posts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update post status (draft/publish/private)
|
||||
*/
|
||||
async updatePostStatus(postId: number, status: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.put(`${this.baseUrl}/posts/${postId}/status`, null, {
|
||||
params: { status }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error updating post status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a WordPress post
|
||||
*/
|
||||
async deletePost(postId: number, force: boolean = false): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.delete(`${this.baseUrl}/posts/${postId}`, {
|
||||
params: { force }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Error deleting post:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test WordPress site connection
|
||||
*/
|
||||
async testConnection(siteData: WordPressSiteRequest): Promise<boolean> {
|
||||
try {
|
||||
// This would typically be a separate endpoint for testing connections
|
||||
// For now, we'll try to add the site and see if it succeeds
|
||||
await this.addSite(siteData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<WordPressHealthResponse> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/health`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress API: Health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate WordPress site URL
|
||||
*/
|
||||
validateSiteUrl(url: string): boolean {
|
||||
try {
|
||||
// Remove protocol if present
|
||||
const cleanUrl = url.replace(/^https?:\/\//, '');
|
||||
|
||||
// Basic URL validation
|
||||
const urlPattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.([a-zA-Z]{2,}|[a-zA-Z]{2,}\.[a-zA-Z]{2,})$/;
|
||||
|
||||
return urlPattern.test(cleanUrl) || cleanUrl.includes('localhost') || cleanUrl.includes('127.0.0.1');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format WordPress site URL
|
||||
*/
|
||||
formatSiteUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const wordpressAPI = new WordPressAPI();
|
||||
export default wordpressAPI;
|
||||
113
frontend/src/api/wordpressOAuth.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* WordPress OAuth2 API client for ALwrity frontend.
|
||||
* Handles WordPress.com OAuth2 authentication flow.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface WordPressOAuthResponse {
|
||||
auth_url: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface WordPressOAuthStatus {
|
||||
connected: boolean;
|
||||
sites: WordPressOAuthSite[];
|
||||
total_sites: number;
|
||||
}
|
||||
|
||||
export interface WordPressOAuthSite {
|
||||
id: number;
|
||||
blog_id: string;
|
||||
blog_url: string;
|
||||
scope: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
class WordPressOAuthAPI {
|
||||
private baseUrl = '/wp';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
* Set authentication token getter
|
||||
*/
|
||||
setAuthTokenGetter(getter: () => Promise<string | null>) {
|
||||
this.getAuthToken = getter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated client with token
|
||||
*/
|
||||
private async getAuthenticatedClient() {
|
||||
const token = this.getAuthToken ? await this.getAuthToken() : null;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No authentication token available');
|
||||
}
|
||||
|
||||
return apiClient.create({
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WordPress OAuth2 authorization URL
|
||||
*/
|
||||
async getAuthUrl(): Promise<WordPressOAuthResponse> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/auth/url`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress OAuth API: Error getting auth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WordPress OAuth connection status
|
||||
*/
|
||||
async getStatus(): Promise<WordPressOAuthStatus> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress OAuth API: Error getting status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a WordPress site
|
||||
*/
|
||||
async disconnectSite(tokenId: number): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.delete(`${this.baseUrl}/disconnect/${tokenId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress OAuth API: Error disconnecting site:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async healthCheck(): Promise<{ status: string; service: string; timestamp: string; version: string }> {
|
||||
try {
|
||||
const response = await apiClient.get(`${this.baseUrl}/health`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('WordPress OAuth API: Health check failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const wordpressOAuthAPI = new WordPressOAuthAPI();
|
||||
export default wordpressOAuthAPI;
|
||||
45
frontend/src/assets/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Assets Directory
|
||||
|
||||
This directory contains all static assets used throughout the ALwrity application.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/assets/
|
||||
├── images/ # Image assets
|
||||
│ ├── alwrity_logo.png # ALwrity company logo
|
||||
│ └── AskAlwrity-min.ico # ALwrity Co-Pilot icon
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### ALwrity Logo (`alwrity_logo.png`)
|
||||
- **Location**: `src/assets/images/alwrity_logo.png`
|
||||
- **Usage**: Company branding in headers, navigation, and branding elements
|
||||
- **Format**: PNG with transparency
|
||||
- **Size**: 188KB, optimized for web
|
||||
|
||||
### ALwrity Co-Pilot Icon (`AskAlwrity-min.ico`)
|
||||
- **Location**: `src/assets/images/AskAlwrity-min.ico`
|
||||
- **Usage**: CopilotKit trigger button icon
|
||||
- **Format**: ICO format for optimal icon display
|
||||
- **Size**: 79KB
|
||||
|
||||
## Import Examples
|
||||
|
||||
```typescript
|
||||
// In components
|
||||
import alwrityLogo from '../../assets/images/alwrity_logo.png';
|
||||
import alwrityIcon from '../../assets/images/AskAlwrity-min.ico';
|
||||
|
||||
// In CSS
|
||||
background-image: url('../../../assets/images/AskAlwrity-min.ico');
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All assets are optimized for web use
|
||||
- ICO format is used for the Co-Pilot icon to ensure crisp display at various sizes
|
||||
- PNG format is used for the logo to maintain transparency
|
||||
- Assets are organized by type for easy maintenance
|
||||
BIN
frontend/src/assets/images/ALwrity-assistive-writing.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
frontend/src/assets/images/Alwrity-fact-check.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
frontend/src/assets/images/AskAlwrity-min.ico
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
frontend/src/assets/images/Fact check1.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
frontend/src/assets/images/alwrity_logo.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,526 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Divider,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Storage as StorageIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Search as SearchIcon,
|
||||
CalendarToday as CalendarIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface AnalyticsSummary {
|
||||
period_days: number;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
total_queries: number;
|
||||
avg_ctr: number;
|
||||
ctr_trend: number;
|
||||
top_queries: Array<{
|
||||
query: string;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
count: number;
|
||||
}>;
|
||||
daily_metrics_count: number;
|
||||
data_quality: string;
|
||||
}
|
||||
|
||||
interface DailyMetric {
|
||||
date: string;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
total_queries: number;
|
||||
avg_ctr: number;
|
||||
avg_position: number;
|
||||
clicks_change: number;
|
||||
impressions_change: number;
|
||||
ctr_change: number;
|
||||
top_queries: any[];
|
||||
collected_at: string;
|
||||
}
|
||||
|
||||
interface TopQuery {
|
||||
query: string;
|
||||
total_clicks: number;
|
||||
total_impressions: number;
|
||||
avg_ctr: number;
|
||||
avg_position: number;
|
||||
days_appeared: number;
|
||||
category: string;
|
||||
is_brand: boolean;
|
||||
}
|
||||
|
||||
const BingAnalyticsStorage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
const [siteUrl, setSiteUrl] = useState('https://www.alwrity.com/');
|
||||
const [days, setDays] = useState(30);
|
||||
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
|
||||
const [dailyMetrics, setDailyMetrics] = useState<DailyMetric[]>([]);
|
||||
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
|
||||
const [sortBy, setSortBy] = useState('clicks');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const loadAnalyticsSummary = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get('/bing-analytics/summary', {
|
||||
params: { site_url: siteUrl, days: days }
|
||||
});
|
||||
|
||||
setSummary(response.data.data);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load analytics summary');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteUrl, days]);
|
||||
|
||||
const collectData = useCallback(async () => {
|
||||
try {
|
||||
setCollecting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
await apiClient.post('/bing-analytics/collect-data', null, {
|
||||
params: { site_url: siteUrl, days_back: days }
|
||||
});
|
||||
|
||||
setSuccess(`Data collection started for ${siteUrl}. This may take a few minutes.`);
|
||||
|
||||
// Refresh summary after a delay
|
||||
setTimeout(() => {
|
||||
loadAnalyticsSummary();
|
||||
}, 5000);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to start data collection');
|
||||
} finally {
|
||||
setCollecting(false);
|
||||
}
|
||||
}, [siteUrl, days, loadAnalyticsSummary]);
|
||||
|
||||
const loadDailyMetrics = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get('/bing-analytics/daily-metrics', {
|
||||
params: { site_url: siteUrl, days: days }
|
||||
});
|
||||
|
||||
setDailyMetrics(response.data.data);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load daily metrics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteUrl, days]);
|
||||
|
||||
const loadTopQueries = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiClient.get('/bing-analytics/top-queries', {
|
||||
params: {
|
||||
site_url: siteUrl,
|
||||
days: days,
|
||||
limit: 20,
|
||||
sort_by: sortBy
|
||||
}
|
||||
});
|
||||
|
||||
setTopQueries(response.data.data);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to load top queries');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteUrl, days, sortBy]);
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
const getChangeColor = (change: number) => {
|
||||
if (change > 0) return 'success';
|
||||
if (change < 0) return 'error';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getChangeIcon = (change: number) => {
|
||||
if (change > 0) return '↗';
|
||||
if (change < 0) return '↘';
|
||||
return '→';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (siteUrl) {
|
||||
loadAnalyticsSummary();
|
||||
}
|
||||
}, [siteUrl, days, loadAnalyticsSummary]);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<StorageIcon color="primary" />
|
||||
Bing Analytics Storage
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
This tool collects and stores Bing Webmaster Tools analytics data for historical analysis and trend tracking.
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Data Collection & Analysis
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Site URL"
|
||||
value={siteUrl}
|
||||
onChange={(e) => setSiteUrl(e.target.value)}
|
||||
placeholder="https://www.example.com/"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Days"
|
||||
type="number"
|
||||
value={days}
|
||||
onChange={(e) => setDays(parseInt(e.target.value) || 30)}
|
||||
inputProps={{ min: 1, max: 365 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={collectData}
|
||||
disabled={collecting || !siteUrl}
|
||||
startIcon={collecting ? <CircularProgress size={20} /> : <RefreshIcon />}
|
||||
fullWidth
|
||||
>
|
||||
{collecting ? 'Collecting...' : 'Collect Data'}
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={loadAnalyticsSummary}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <AssessmentIcon />}
|
||||
fullWidth
|
||||
>
|
||||
Refresh Summary
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Analytics Summary */}
|
||||
{summary && (
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TrendingUpIcon color="primary" />
|
||||
Analytics Summary ({summary.period_days} days)
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="primary">
|
||||
{formatNumber(summary.total_clicks)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total Clicks
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="secondary">
|
||||
{formatNumber(summary.total_impressions)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total Impressions
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="info">
|
||||
{summary.avg_ctr.toFixed(2)}%
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Avg CTR
|
||||
<Chip
|
||||
label={`${getChangeIcon(summary.ctr_trend)} ${summary.ctr_trend.toFixed(1)}%`}
|
||||
color={getChangeColor(summary.ctr_trend)}
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="success">
|
||||
{summary.total_queries}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Unique Queries
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Top Performing Queries
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
{summary.top_queries.slice(0, 5).map((query, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemIcon>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{index + 1}
|
||||
</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={query.query}
|
||||
secondary={`${query.clicks} clicks • ${query.impressions} impressions • ${((query.clicks / query.impressions) * 100).toFixed(1)}% CTR`}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Chip
|
||||
label={`Data Quality: ${summary.data_quality}`}
|
||||
color={summary.data_quality === 'good' ? 'success' : 'warning'}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Top Queries Table */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon color="primary" />
|
||||
Top Queries
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Sort By</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sort By"
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<MenuItem value="clicks">Clicks</MenuItem>
|
||||
<MenuItem value="impressions">Impressions</MenuItem>
|
||||
<MenuItem value="ctr">CTR</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={loadTopQueries}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <SearchIcon />}
|
||||
>
|
||||
Load Top Queries
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{topQueries.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Query</TableCell>
|
||||
<TableCell align="right">Clicks</TableCell>
|
||||
<TableCell align="right">Impressions</TableCell>
|
||||
<TableCell align="right">CTR</TableCell>
|
||||
<TableCell align="right">Avg Position</TableCell>
|
||||
<TableCell align="right">Days</TableCell>
|
||||
<TableCell>Category</TableCell>
|
||||
<TableCell>Brand</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{topQueries.map((query, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{query.query}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">{query.total_clicks}</TableCell>
|
||||
<TableCell align="right">{query.total_impressions}</TableCell>
|
||||
<TableCell align="right">{query.avg_ctr.toFixed(1)}%</TableCell>
|
||||
<TableCell align="right">{query.avg_position > 0 ? query.avg_position.toFixed(1) : 'N/A'}</TableCell>
|
||||
<TableCell align="right">{query.days_appeared}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={query.category} size="small" color="default" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={query.is_brand ? 'Brand' : 'Generic'}
|
||||
size="small"
|
||||
color={query.is_brand ? 'primary' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Daily Metrics */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CalendarIcon color="primary" />
|
||||
Daily Metrics
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={loadDailyMetrics}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <CalendarIcon />}
|
||||
>
|
||||
Load Daily Data
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{dailyMetrics.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell align="right">Clicks</TableCell>
|
||||
<TableCell align="right">Impressions</TableCell>
|
||||
<TableCell align="right">Queries</TableCell>
|
||||
<TableCell align="right">CTR</TableCell>
|
||||
<TableCell align="right">Position</TableCell>
|
||||
<TableCell align="right">Clicks Δ</TableCell>
|
||||
<TableCell align="right">CTR Δ</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{dailyMetrics.slice(0, 10).map((metric, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{new Date(metric.date).toLocaleDateString()}</TableCell>
|
||||
<TableCell align="right">{metric.total_clicks}</TableCell>
|
||||
<TableCell align="right">{metric.total_impressions}</TableCell>
|
||||
<TableCell align="right">{metric.total_queries}</TableCell>
|
||||
<TableCell align="right">{metric.avg_ctr.toFixed(1)}%</TableCell>
|
||||
<TableCell align="right">{metric.avg_position > 0 ? metric.avg_position.toFixed(1) : 'N/A'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={`${getChangeIcon(metric.clicks_change)} ${metric.clicks_change.toFixed(1)}%`}
|
||||
color={getChangeColor(metric.clicks_change)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Chip
|
||||
label={`${getChangeIcon(metric.ctr_change)} ${metric.ctr_change.toFixed(1)}%`}
|
||||
color={getChangeColor(metric.ctr_change)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BingAnalyticsStorage;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
|
||||
|
||||
const BingCallbackPage: React.FC = () => {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const error = params.get('error');
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
throw new Error('Missing OAuth parameters');
|
||||
}
|
||||
|
||||
try {
|
||||
// Call backend to complete token exchange
|
||||
await fetch(`/bing/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (e) {
|
||||
// Continue; backend HTML callback may already be handled in popup
|
||||
}
|
||||
|
||||
// Notify opener and close if this is a popup window
|
||||
try {
|
||||
(window.opener || window.parent)?.postMessage({ type: 'BING_OAUTH_SUCCESS', success: true }, '*');
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback: redirect back to onboarding
|
||||
window.location.replace('/onboarding?step=5');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'OAuth callback failed');
|
||||
try {
|
||||
(window.opener || window.parent)?.postMessage({ type: 'BING_OAUTH_ERROR', success: false, error: e?.message || 'OAuth callback failed' }, '*');
|
||||
if (window.opener) window.close();
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
padding={3}
|
||||
>
|
||||
{error ? (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">Connection Failed</Typography>
|
||||
<Typography>{error}</Typography>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<CircularProgress sx={{ mb: 2 }} />
|
||||
<Typography variant="h6">Connecting to Bing Webmaster Tools...</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Please wait while we complete the authentication process.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BingCallbackPage;
|
||||
561
frontend/src/components/BlogWriter/BlogWriter.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { debug } from '../../utils/debug';
|
||||
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
||||
import HallucinationChecker from './HallucinationChecker';
|
||||
import Publisher from './Publisher';
|
||||
import OutlineGenerator from './OutlineGenerator';
|
||||
import OutlineRefiner from './OutlineRefiner';
|
||||
import { SEOProcessor } from './SEO';
|
||||
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
|
||||
import { SEOAnalysisModal } from './SEOAnalysisModal';
|
||||
import { SEOMetadataModal } from './SEOMetadataModal';
|
||||
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
|
||||
import HeaderBar from './BlogWriterUtils/HeaderBar';
|
||||
import PhaseContent from './BlogWriterUtils/PhaseContent';
|
||||
import useBlogWriterCopilotActions from './BlogWriterUtils/useBlogWriterCopilotActions';
|
||||
import { useCopilotKitHealth } from '../../hooks/useCopilotKitHealth';
|
||||
import { useSEOManager } from './BlogWriterUtils/useSEOManager';
|
||||
import { usePhaseActionHandlers } from './BlogWriterUtils/usePhaseActionHandlers';
|
||||
import { useBlogWriterPolling } from './BlogWriterUtils/useBlogWriterPolling';
|
||||
import { useCopilotSuggestions } from './BlogWriterUtils/useCopilotSuggestions';
|
||||
import { usePhaseRestoration } from './BlogWriterUtils/usePhaseRestoration';
|
||||
import { useModalVisibility } from './BlogWriterUtils/useModalVisibility';
|
||||
import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
|
||||
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
|
||||
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
||||
|
||||
export const BlogWriter: React.FC = () => {
|
||||
// Add light theme class to body/html on mount, remove on unmount
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('blog-writer-page');
|
||||
document.documentElement.classList.add('blog-writer-page');
|
||||
return () => {
|
||||
document.body.classList.remove('blog-writer-page');
|
||||
document.documentElement.classList.remove('blog-writer-page');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check CopilotKit health status
|
||||
const { isAvailable: copilotKitAvailable } = useCopilotKitHealth({
|
||||
enabled: true, // Enable health checking
|
||||
});
|
||||
|
||||
// Use custom hook for all state management
|
||||
const {
|
||||
research,
|
||||
outline,
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
sections,
|
||||
seoAnalysis,
|
||||
genMode,
|
||||
seoMetadata,
|
||||
continuityRefresh,
|
||||
outlineTaskId,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
outlineConfirmed,
|
||||
contentConfirmed,
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
sectionImages,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
setSelectedTitle,
|
||||
setSections,
|
||||
setSeoAnalysis,
|
||||
setGenMode,
|
||||
setSeoMetadata,
|
||||
setContinuityRefresh,
|
||||
setOutlineTaskId,
|
||||
setContentConfirmed,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
setSectionImages,
|
||||
handleResearchComplete,
|
||||
handleOutlineComplete,
|
||||
handleOutlineError,
|
||||
handleTitleSelect,
|
||||
handleCustomTitle,
|
||||
handleOutlineConfirmed,
|
||||
handleOutlineRefined,
|
||||
handleContentUpdate,
|
||||
handleContentSave
|
||||
} = useBlogWriterState();
|
||||
|
||||
// SEO Manager - handles all SEO-related logic
|
||||
// Initialize phase navigation with temporary false value for seoRecommendationsApplied
|
||||
const [tempSeoRecommendationsApplied] = React.useState(false);
|
||||
const {
|
||||
phases: tempPhases,
|
||||
currentPhase: tempCurrentPhase,
|
||||
navigateToPhase: tempNavigateToPhase,
|
||||
setCurrentPhase: tempSetCurrentPhase,
|
||||
resetUserSelection
|
||||
} = usePhaseNavigation(
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
Object.keys(sections).length > 0,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
tempSeoRecommendationsApplied
|
||||
);
|
||||
|
||||
const {
|
||||
isSEOAnalysisModalOpen,
|
||||
setIsSEOAnalysisModalOpen,
|
||||
isSEOMetadataModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
seoRecommendationsApplied,
|
||||
setSeoRecommendationsApplied,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
handleApplySeoRecommendations,
|
||||
handleSEOAnalysisComplete,
|
||||
handleSEOModalClose,
|
||||
confirmBlogContent,
|
||||
} = useSEOManager({
|
||||
sections,
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
currentPhase: tempCurrentPhase,
|
||||
navigateToPhase: tempNavigateToPhase,
|
||||
setContentConfirmed,
|
||||
setSeoAnalysis,
|
||||
setSeoMetadata,
|
||||
setSections,
|
||||
setSelectedTitle: setSelectedTitle as (title: string | null) => void,
|
||||
setContinuityRefresh,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
});
|
||||
|
||||
// Phase navigation hook with correct seoRecommendationsApplied
|
||||
const {
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
} = usePhaseNavigation(
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
Object.keys(sections).length > 0,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied
|
||||
);
|
||||
|
||||
// Update ref when navigateToPhase changes
|
||||
React.useEffect(() => {
|
||||
navigateToPhaseRef.current = navigateToPhase;
|
||||
}, [navigateToPhase]);
|
||||
|
||||
// Phase restoration logic
|
||||
usePhaseRestoration({
|
||||
copilotKitAvailable,
|
||||
research,
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
});
|
||||
|
||||
// All SEO management logic is now in useSEOManager hook above
|
||||
|
||||
// Custom hooks for complex functionality
|
||||
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
|
||||
outline,
|
||||
sections,
|
||||
setSections
|
||||
);
|
||||
|
||||
const { convertMarkdownToHTML } = useMarkdownProcessor(
|
||||
outline,
|
||||
sections
|
||||
);
|
||||
|
||||
// Store navigateToPhase in a ref for use in polling callbacks
|
||||
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
|
||||
|
||||
// Polling hooks - extracted to useBlogWriterPolling
|
||||
const {
|
||||
researchPolling,
|
||||
outlinePolling,
|
||||
mediumPolling,
|
||||
rewritePolling,
|
||||
researchPollingState,
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
} = useBlogWriterPolling({
|
||||
onResearchComplete: handleResearchComplete,
|
||||
onOutlineComplete: handleOutlineComplete,
|
||||
onOutlineError: handleOutlineError,
|
||||
onSectionsUpdate: setSections,
|
||||
onContentConfirmed: () => {
|
||||
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
|
||||
setContentConfirmed(true);
|
||||
},
|
||||
navigateToPhase: (phase) => {
|
||||
debug.log('[BlogWriter] Navigating to phase after content generation', { phase });
|
||||
// Use ref to access navigateToPhase (defined later in component)
|
||||
if (navigateToPhaseRef.current) {
|
||||
setTimeout(() => {
|
||||
navigateToPhaseRef.current?.(phase);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Modal visibility management - extracted to useModalVisibility
|
||||
const {
|
||||
showModal,
|
||||
showOutlineModal,
|
||||
setShowOutlineModal,
|
||||
isMediumGenerationStarting,
|
||||
setIsMediumGenerationStarting,
|
||||
} = useModalVisibility({
|
||||
mediumPolling,
|
||||
rewritePolling,
|
||||
outlinePolling,
|
||||
});
|
||||
|
||||
// CopilotKit suggestions management - extracted to useCopilotSuggestions
|
||||
// Check if sections exist AND have actual content (not just empty strings)
|
||||
const hasContent = React.useMemo(() => {
|
||||
const sectionKeys = Object.keys(sections);
|
||||
if (sectionKeys.length === 0) return false;
|
||||
// Check if at least one section has actual content
|
||||
const sectionsWithContent = Object.values(sections).filter(c => c && c.trim().length > 0);
|
||||
return sectionsWithContent.length > 0;
|
||||
}, [sections]);
|
||||
const {
|
||||
suggestions,
|
||||
setSuggestionsRef,
|
||||
} = useCopilotSuggestions({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPollingState,
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
hasContent,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied,
|
||||
});
|
||||
|
||||
// Refs and tracking logic - extracted to useBlogWriterRefs
|
||||
useBlogWriterRefs({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
currentPhase,
|
||||
isSEOAnalysisModalOpen,
|
||||
resetUserSelection,
|
||||
});
|
||||
|
||||
const handlePhaseClick = useCallback((phaseId: string) => {
|
||||
navigateToPhase(phaseId);
|
||||
if (phaseId === 'seo') {
|
||||
if (seoAnalysis) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] SEO modal opened (phase navigation)');
|
||||
} else {
|
||||
runSEOAnalysisDirect();
|
||||
}
|
||||
}
|
||||
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
const outlineGenRef = useRef<any>(null);
|
||||
|
||||
// Callback to handle cached outline completion
|
||||
const handleCachedOutlineComplete = useCallback((result: { outline: any[], title_options?: string[] }) => {
|
||||
if (result.outline && Array.isArray(result.outline)) {
|
||||
handleOutlineComplete(result);
|
||||
}
|
||||
}, [handleOutlineComplete]);
|
||||
|
||||
// Callback to handle cached content completion
|
||||
const handleCachedContentComplete = useCallback((cachedSections: Record<string, string>) => {
|
||||
if (cachedSections && Object.keys(cachedSections).length > 0) {
|
||||
setSections(cachedSections);
|
||||
debug.log('[BlogWriter] Cached content loaded into state', { sections: Object.keys(cachedSections).length });
|
||||
}
|
||||
}, [setSections]);
|
||||
|
||||
// Phase action handlers for when CopilotKit is unavailable - extracted to usePhaseActionHandlers
|
||||
const {
|
||||
handleResearchAction,
|
||||
handleOutlineAction,
|
||||
handleContentAction,
|
||||
handleSEOAction,
|
||||
handleApplySEORecommendations,
|
||||
handlePublishAction,
|
||||
} = usePhaseActionHandlers({
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
navigateToPhase,
|
||||
handleOutlineConfirmed,
|
||||
setIsMediumGenerationStarting,
|
||||
mediumPolling,
|
||||
outlineGenRef,
|
||||
setOutline,
|
||||
setContentConfirmed,
|
||||
setIsSEOAnalysisModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
runSEOAnalysisDirect,
|
||||
onOutlineComplete: handleCachedOutlineComplete,
|
||||
onContentComplete: handleCachedContentComplete,
|
||||
});
|
||||
|
||||
// Handle medium generation start from OutlineFeedbackForm
|
||||
const handleMediumGenerationStarted = (taskId: string) => {
|
||||
console.log('Starting medium generation polling for task:', taskId);
|
||||
setIsMediumGenerationStarting(false); // Clear the starting state
|
||||
mediumPolling.startPolling(taskId);
|
||||
};
|
||||
|
||||
// Show modal immediately when copilot action is triggered
|
||||
const handleMediumGenerationTriggered = () => {
|
||||
console.log('Medium generation triggered - showing modal immediately');
|
||||
setIsMediumGenerationStarting(true);
|
||||
};
|
||||
|
||||
useBlogWriterCopilotActions({
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||
navigateToPhase,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1a1a1a',
|
||||
overflow: 'auto'
|
||||
}} className="blog-writer-container">
|
||||
{/* CopilotKit-dependent components - extracted to CopilotKitComponents */}
|
||||
{copilotKitAvailable && (
|
||||
<CopilotKitComponents
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
sections={sections}
|
||||
selectedTitle={selectedTitle}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
onOutlineCreated={setOutline}
|
||||
onOutlineUpdated={setOutline}
|
||||
onTitleOptionsSet={setTitleOptions}
|
||||
onOutlineConfirmed={handleOutlineConfirmed}
|
||||
onOutlineRefined={(feedback?: string) => handleOutlineRefined(feedback || '')}
|
||||
onMediumGenerationStarted={handleMediumGenerationStarted}
|
||||
onMediumGenerationTriggered={handleMediumGenerationTriggered}
|
||||
onRewriteStarted={(taskId) => {
|
||||
console.log('Starting rewrite polling for task:', taskId);
|
||||
rewritePolling.startPolling(taskId);
|
||||
}}
|
||||
onRewriteTriggered={() => {
|
||||
console.log('Rewrite triggered - showing modal immediately');
|
||||
setIsMediumGenerationStarting(true);
|
||||
}}
|
||||
setFlowAnalysisCompleted={setFlowAnalysisCompleted}
|
||||
setFlowAnalysisResults={setFlowAnalysisResults}
|
||||
setContinuityRefresh={setContinuityRefresh}
|
||||
researchPolling={researchPolling}
|
||||
navigateToPhase={navigateToPhase}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New extracted functionality components */}
|
||||
<OutlineGenerator
|
||||
ref={outlineGenRef}
|
||||
research={research}
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
onModalShow={() => setShowOutlineModal(true)}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onOutlineCreated={(outline, titleOptions) => {
|
||||
// Handle cached outline from CopilotKit action (same as header button)
|
||||
setOutline(outline);
|
||||
if (titleOptions) {
|
||||
setTitleOptions(titleOptions);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OutlineRefiner
|
||||
outline={outline}
|
||||
onOutlineUpdated={setOutline}
|
||||
/>
|
||||
<SEOProcessor
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
seoMetadata={seoMetadata}
|
||||
onSEOAnalysis={setSeoAnalysis}
|
||||
onSEOMetadata={setSeoMetadata}
|
||||
/>
|
||||
<HallucinationChecker
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
buildUpdatedMarkdownForClaim={buildUpdatedMarkdownForClaim}
|
||||
applyClaimFix={applyClaimFix}
|
||||
/>
|
||||
<Publisher
|
||||
buildFullMarkdown={buildFullMarkdown}
|
||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||
seoMetadata={seoMetadata}
|
||||
/>
|
||||
|
||||
{/* Phase navigation header - always visible as default interface */}
|
||||
<HeaderBar
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={{
|
||||
onResearchAction: handleResearchAction,
|
||||
onOutlineAction: handleOutlineAction,
|
||||
onContentAction: handleContentAction,
|
||||
onSEOAction: handleSEOAction,
|
||||
onApplySEORecommendations: handleApplySEORecommendations,
|
||||
onPublishAction: handlePublishAction,
|
||||
}}
|
||||
hasResearch={!!research}
|
||||
hasOutline={outline.length > 0}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={Object.keys(sections).length > 0}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={!!seoAnalysis}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
hasSEOMetadata={!!seoMetadata}
|
||||
/>
|
||||
|
||||
{/* Landing section - extracted to BlogWriterLandingSection */}
|
||||
<BlogWriterLandingSection
|
||||
research={research}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
currentPhase={currentPhase}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
/>
|
||||
|
||||
{research && (
|
||||
<>
|
||||
<PhaseContent
|
||||
currentPhase={currentPhase}
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
setOutline={setOutline}
|
||||
sections={sections}
|
||||
handleContentUpdate={handleContentUpdate}
|
||||
handleContentSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
outlineGenRef={outlineGenRef}
|
||||
blogWriterApi={blogWriterApi}
|
||||
sectionImages={sectionImages}
|
||||
setSectionImages={setSectionImages}
|
||||
contentConfirmed={contentConfirmed}
|
||||
seoAnalysis={seoAnalysis}
|
||||
seoMetadata={seoMetadata}
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
onResearchComplete={handleResearchComplete}
|
||||
onOutlineGenerationStart={(taskId) => {
|
||||
setOutlineTaskId(taskId);
|
||||
outlinePolling.startPolling(taskId);
|
||||
setShowOutlineModal(true);
|
||||
}}
|
||||
onContentGenerationStart={handleMediumGenerationStarted}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<WriterCopilotSidebar
|
||||
suggestions={suggestions}
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
/>
|
||||
|
||||
<TaskProgressModals
|
||||
showOutlineModal={showOutlineModal}
|
||||
outlinePolling={outlinePolling}
|
||||
showModal={showModal}
|
||||
rewritePolling={rewritePolling}
|
||||
mediumPolling={mediumPolling}
|
||||
/>
|
||||
|
||||
{/* SEO Analysis Modal */}
|
||||
<SEOAnalysisModal
|
||||
isOpen={isSEOAnalysisModalOpen}
|
||||
onClose={handleSEOModalClose}
|
||||
blogContent={buildFullMarkdown()}
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
onApplyRecommendations={handleApplySeoRecommendations}
|
||||
onAnalysisComplete={handleSEOAnalysisComplete}
|
||||
/>
|
||||
|
||||
{/* SEO Metadata Modal */}
|
||||
<SEOMetadataModal
|
||||
isOpen={isSEOMetadataModalOpen}
|
||||
onClose={() => setIsSEOMetadataModalOpen(false)}
|
||||
blogContent={buildFullMarkdown()}
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
outline={outline}
|
||||
seoAnalysis={seoAnalysis}
|
||||
onMetadataGenerated={(metadata) => {
|
||||
console.log('SEO metadata generated:', metadata);
|
||||
setSeoMetadata(metadata);
|
||||
// Metadata is now saved and will be used when publishing to WordPress/Wix
|
||||
// The metadata includes all SEO fields (title, description, tags, Open Graph, etc.)
|
||||
// Publisher component will use this metadata when calling publish API
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriter;
|
||||
68
frontend/src/components/BlogWriter/BlogWriterLanding.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# BlogWriterLanding Component
|
||||
|
||||
A beautiful, animated landing page for the ALwrity Blog Writer that utilizes the custom background image with artistic button placement and subtle animations.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎨 **Visual Design**
|
||||
- **Full-screen background image** (`/blog-writer-bg.png`) with horizontal stretching (56% width) and left alignment
|
||||
- **Gradient overlays** for subtle depth
|
||||
- **Clean, minimal design** without decorative elements
|
||||
- **Glassmorphism effects** on secondary buttons
|
||||
|
||||
### ✨ **Interactions**
|
||||
- **Button hover effects** with smooth transitions
|
||||
- **Modal interactions** with clean transitions
|
||||
- **Responsive hover states** for all interactive elements
|
||||
|
||||
### 🚀 **Interactive Elements**
|
||||
- **Primary CTA Button**: "Chat/Write with ALwrity Copilot" with gradient background
|
||||
- **Secondary CTA Button**: "ALwrity Blog Writer SuperPowers" opens feature modal
|
||||
- **SuperPowers Modal**: Showcases 6 key features with hover effects
|
||||
- **Responsive design** that works on all screen sizes
|
||||
|
||||
### 🎯 **User Experience**
|
||||
- **Clear messaging** about the blog writing capabilities
|
||||
- **Feature showcase** in an engaging modal format
|
||||
- **Clean, focused messaging** without distracting text
|
||||
- **Clean transitions** between states
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import BlogWriterLanding from './BlogWriterLanding';
|
||||
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Handle start writing action
|
||||
// This can trigger copilot interaction
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
- `onStartWriting: () => void` - Callback function called when user clicks "Chat/Write with ALwrity Copilot"
|
||||
|
||||
## Integration
|
||||
|
||||
The component integrates with:
|
||||
- **useCopilotTrigger hook** for copilot interaction
|
||||
- **BlogWriter main component** as the initial state
|
||||
- **Responsive design** that adapts to different screen sizes
|
||||
|
||||
## Styling
|
||||
|
||||
All styles are inline with CSS-in-JS approach for:
|
||||
- **Better performance** (no external CSS files)
|
||||
- **Component isolation** (styles don't leak)
|
||||
- **Dynamic theming** (easy to modify colors/effects)
|
||||
- **Animation control** (precise timing and effects)
|
||||
|
||||
## Accessibility
|
||||
|
||||
- **Semantic HTML** structure
|
||||
- **Keyboard navigation** support
|
||||
- **Screen reader** friendly
|
||||
- **High contrast** text and buttons
|
||||
- **Focus indicators** for interactive elements
|
||||
383
frontend/src/components/BlogWriter/BlogWriterLanding.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
|
||||
import BlogWriterPhasesSection from './BlogWriterPhasesSection';
|
||||
|
||||
interface BlogWriterLandingProps {
|
||||
onStartWriting: () => void;
|
||||
}
|
||||
|
||||
const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting }) => {
|
||||
const [showSuperPowers, setShowSuperPowers] = useState(false);
|
||||
const { triggerResearch } = useCopilotTrigger();
|
||||
|
||||
const handleStartWriting = () => {
|
||||
// Open the copilot sidebar (same functionality as LinkedIn writer)
|
||||
const copilotButton = document.querySelector('.copilotkit-open-button') ||
|
||||
document.querySelector('[data-copilot-open]') ||
|
||||
document.querySelector('button[aria-label*="Open"]') ||
|
||||
document.querySelector('.alwrity-copilot-sidebar button');
|
||||
|
||||
if (copilotButton) {
|
||||
(copilotButton as HTMLElement).click();
|
||||
} else {
|
||||
// Fallback: scroll to bottom right where the button should be
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// Also call the parent callback
|
||||
onStartWriting();
|
||||
};
|
||||
|
||||
const superPowers = [
|
||||
{
|
||||
icon: "🔍",
|
||||
title: "AI-Powered Research",
|
||||
description: "Comprehensive research with Google Search grounding, competitor analysis, and content gap identification"
|
||||
},
|
||||
{
|
||||
icon: "📝",
|
||||
title: "Intelligent Outline Generation",
|
||||
description: "AI-generated outlines with source mapping, grounding insights, and optimization recommendations"
|
||||
},
|
||||
{
|
||||
icon: "✨",
|
||||
title: "Content Enhancement",
|
||||
description: "Section-by-section content generation with SEO optimization and engagement improvements"
|
||||
},
|
||||
{
|
||||
icon: "🎯",
|
||||
title: "SEO Intelligence",
|
||||
description: "Advanced SEO analysis, metadata generation, and keyword optimization for maximum visibility"
|
||||
},
|
||||
{
|
||||
icon: "🔍",
|
||||
title: "Fact-Checking & Quality",
|
||||
description: "Hallucination detection, claim verification, and content quality assurance"
|
||||
},
|
||||
{
|
||||
icon: "🚀",
|
||||
title: "Multi-Platform Publishing",
|
||||
description: "Direct publishing to WordPress, Wix, and other platforms with scheduling capabilities"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
minHeight: '100vh',
|
||||
backgroundImage: 'url(/blog-writer-bg.png)',
|
||||
backgroundSize: '56% auto',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundColor: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
|
||||
{/* Main content container */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
textAlign: 'center',
|
||||
maxWidth: '800px',
|
||||
padding: '40px 20px'
|
||||
}}>
|
||||
{/* Main heading */}
|
||||
<div style={{
|
||||
marginBottom: '40px'
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '3.5rem',
|
||||
fontWeight: '700',
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
margin: '0 0 20px 0',
|
||||
textShadow: '0 4px 8px rgba(0,0,0,0.1)',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
AI-First, Contextual, Click through Blog Writer
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '24px',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{/* Primary CTA Button */}
|
||||
<button
|
||||
onClick={handleStartWriting}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '18px 48px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 8px 25px rgba(25, 118, 210, 0.3)',
|
||||
transition: 'all 0.3s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
minWidth: '280px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-3px) scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 12px 35px rgba(25, 118, 210, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
>
|
||||
<span style={{ position: 'relative', zIndex: 2 }}>
|
||||
✨ Chat/Write with ALwrity Copilot
|
||||
</span>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent)',
|
||||
transition: 'left 0.5s ease'
|
||||
}} />
|
||||
</button>
|
||||
|
||||
{/* Secondary CTA Button */}
|
||||
<button
|
||||
onClick={() => setShowSuperPowers(true)}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#1976d2',
|
||||
border: '2px solid #1976d2',
|
||||
padding: '14px 36px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 15px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.3s ease',
|
||||
backdropFilter: 'blur(10px)',
|
||||
minWidth: '280px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#1976d2';
|
||||
e.currentTarget.style.color = 'white';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||
e.currentTarget.style.color = '#1976d2';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(0,0,0,0.1)';
|
||||
}}
|
||||
>
|
||||
🚀 ALwrity Blog Writer SuperPowers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SuperPowers Modal with 6 Phases */}
|
||||
{showSuperPowers && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
width: '100%',
|
||||
maxWidth: '1400px',
|
||||
minHeight: '100%',
|
||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '30px',
|
||||
paddingBottom: '20px',
|
||||
borderBottom: '2px solid #f0f0f0'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: '0 0 8px 0',
|
||||
fontSize: '2rem',
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text'
|
||||
}}>
|
||||
🚀 ALwrity Blog Writer SuperPowers
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#666', fontSize: '1.1rem' }}>
|
||||
Discover the powerful features that make ALwrity the ultimate blog writing assistant
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSuperPowers(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '2rem',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
padding: '8px',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f0f0f0';
|
||||
e.currentTarget.style.color = '#333';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#999';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 6 Phases Section */}
|
||||
<BlogWriterPhasesSection />
|
||||
|
||||
{/* Quick SuperPowers Grid */}
|
||||
<div style={{ padding: '40px', borderTop: '1px solid #f0f0f0' }}>
|
||||
<h2 style={{
|
||||
margin: '0 0 20px 0',
|
||||
fontSize: '1.5rem',
|
||||
textAlign: 'center',
|
||||
color: '#333'
|
||||
}}>
|
||||
Quick Feature Overview
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
{superPowers.map((power, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '2rem',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
||||
borderRadius: '12px'
|
||||
}}>
|
||||
{power.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '1.1rem',
|
||||
color: '#333',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{power.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: '#666',
|
||||
lineHeight: '1.6',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
{power.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{
|
||||
marginTop: '30px',
|
||||
paddingTop: '20px',
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<button
|
||||
onClick={handleStartWriting}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '16px 32px',
|
||||
borderRadius: '50px',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.3)',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(25, 118, 210, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(25, 118, 210, 0.3)';
|
||||
}}
|
||||
>
|
||||
✨ Chat/Write with ALwrity Copilot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriterLanding;
|
||||
576
frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Grid, Card, CardContent, Typography, Box, Stack, Chip } from '@mui/material';
|
||||
import { CheckCircle, AutoAwesome } from '@mui/icons-material';
|
||||
|
||||
interface PhaseFeature {
|
||||
title: string;
|
||||
description: string;
|
||||
details: string[];
|
||||
imagePlaceholder: string;
|
||||
}
|
||||
|
||||
interface BlogPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
shortDescription: string;
|
||||
features: PhaseFeature[];
|
||||
technicalDetails: {
|
||||
aiModel: string;
|
||||
promptType: string;
|
||||
outputFormat: string;
|
||||
integration: string;
|
||||
};
|
||||
videoPlaceholder: string;
|
||||
}
|
||||
|
||||
const BlogWriterPhasesSection: React.FC = () => {
|
||||
const [activePhase, setActivePhase] = useState<number | null>(null);
|
||||
|
||||
const phases: BlogPhase[] = [
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Research & Strategy',
|
||||
icon: '🔍',
|
||||
shortDescription: 'AI-powered comprehensive research with Google Search grounding, competitor analysis, and content gap identification',
|
||||
features: [
|
||||
{
|
||||
title: 'Google Search Grounding',
|
||||
description: 'Real-time web research using Gemini\'s native Google Search integration',
|
||||
details: [
|
||||
'Single API call for comprehensive research',
|
||||
'Live web data from credible sources',
|
||||
'Automatic source extraction and citation',
|
||||
'Current trends and 2024-2025 insights',
|
||||
'Market analysis and forecasts'
|
||||
],
|
||||
imagePlaceholder: '/images/research-google-grounding.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Competitor Analysis',
|
||||
description: 'Identify top players and content opportunities in your niche',
|
||||
details: [
|
||||
'Top competitor content analysis',
|
||||
'Content gap identification',
|
||||
'Unique angle discovery',
|
||||
'Market positioning insights',
|
||||
'Competitive advantage opportunities'
|
||||
],
|
||||
imagePlaceholder: '/images/research-competitor.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Keyword Intelligence',
|
||||
description: 'Comprehensive keyword analysis with SEO opportunities',
|
||||
details: [
|
||||
'Primary, secondary, and long-tail keyword identification',
|
||||
'Search volume and competition analysis',
|
||||
'Keyword clustering and grouping',
|
||||
'Content optimization suggestions',
|
||||
'Target audience keyword mapping'
|
||||
],
|
||||
imagePlaceholder: '/images/research-keywords.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Content Angle Generation',
|
||||
description: 'AI-generated compelling content angles for maximum engagement',
|
||||
details: [
|
||||
'5 unique content angle suggestions',
|
||||
'Trending topic identification',
|
||||
'Audience pain point mapping',
|
||||
'Viral potential assessment',
|
||||
'Expert opinion synthesis'
|
||||
],
|
||||
imagePlaceholder: '/images/research-angles.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Gemini Pro with Google Search Grounding',
|
||||
promptType: 'Comprehensive research prompt',
|
||||
outputFormat: 'Structured JSON with sources, keywords, trends, competitors',
|
||||
integration: 'GeminiGroundedProvider via research_service.py'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase1-research.mp4'
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Intelligent Outline',
|
||||
icon: '📝',
|
||||
shortDescription: 'AI-generated outlines with source mapping, grounding insights, and optimization recommendations',
|
||||
features: [
|
||||
{
|
||||
title: 'AI Outline Generation',
|
||||
description: 'Comprehensive outline based on research with SEO optimization',
|
||||
details: [
|
||||
'Section-by-section breakdown',
|
||||
'Subheadings and key points',
|
||||
'Target word counts per section',
|
||||
'Logical flow and progression',
|
||||
'SEO-optimized structure'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-generation.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Source Mapping & Grounding',
|
||||
description: 'Connect each section to research sources with citations',
|
||||
details: [
|
||||
'Automatic source-to-section mapping',
|
||||
'Grounding support scores',
|
||||
'Citation suggestions',
|
||||
'Source credibility ratings',
|
||||
'Reference verification'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-grounding.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Interactive Refinement',
|
||||
description: 'Human-in-the-loop editing with AI assistance',
|
||||
details: [
|
||||
'Add, remove, merge sections',
|
||||
'Reorder and restructure',
|
||||
'AI enhancement suggestions',
|
||||
'Custom instructions support',
|
||||
'Multiple outline versions'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-refine.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Title Generation',
|
||||
description: 'Multiple SEO-optimized title options',
|
||||
details: [
|
||||
'AI-generated title variations',
|
||||
'SEO score per title',
|
||||
'Engagement potential analysis',
|
||||
'Keyword integration',
|
||||
'Click-through optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-titles.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Gemini Pro (provider-agnostic via llm_text_gen)',
|
||||
promptType: 'Structured outline prompt with research context',
|
||||
outputFormat: 'JSON outline with sections, headings, key_points, references',
|
||||
integration: 'OutlineService via parallel_processor.py'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase2-outline.mp4'
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
name: 'Content Generation',
|
||||
icon: '✨',
|
||||
shortDescription: 'Section-by-section content generation with SEO optimization, context memory, and engagement improvements',
|
||||
features: [
|
||||
{
|
||||
title: 'Smart Content Generation',
|
||||
description: 'AI-powered section writing with context awareness',
|
||||
details: [
|
||||
'Section-by-section generation',
|
||||
'Context memory across sections',
|
||||
'Smooth transitions between sections',
|
||||
'Consistent tone and style',
|
||||
'Natural keyword integration'
|
||||
],
|
||||
imagePlaceholder: '/images/content-generation.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Continuity Analysis',
|
||||
description: 'Real-time flow and coherence monitoring',
|
||||
details: [
|
||||
'Narrative flow assessment',
|
||||
'Coherence scoring',
|
||||
'Transition quality analysis',
|
||||
'Tone consistency tracking',
|
||||
'Content quality metrics'
|
||||
],
|
||||
imagePlaceholder: '/images/content-continuity.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Source Integration',
|
||||
description: 'Automatic citation and source reference',
|
||||
details: [
|
||||
'Relevant URL selection',
|
||||
'Natural citation insertion',
|
||||
'Source attribution',
|
||||
'Evidence-backed content',
|
||||
'Reference management'
|
||||
],
|
||||
imagePlaceholder: '/images/content-sources.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Medium Blog Mode',
|
||||
description: 'Quick generation for Medium-style articles',
|
||||
details: [
|
||||
'Single-call full blog generation',
|
||||
'Medium-optimized formatting',
|
||||
'Engagement-focused structure',
|
||||
'SEO-ready output',
|
||||
'Fast turnaround option'
|
||||
],
|
||||
imagePlaceholder: '/images/content-medium.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Provider-agnostic (Gemini/HF via main_text_generation)',
|
||||
promptType: 'Context-aware section prompt with research',
|
||||
outputFormat: 'Markdown content with transitions and metrics',
|
||||
integration: 'EnhancedContentGenerator with ContextMemory'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase3-content.mp4'
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
name: 'SEO Analysis',
|
||||
icon: '📈',
|
||||
shortDescription: 'Advanced SEO analysis with actionable recommendations and AI-powered optimization',
|
||||
features: [
|
||||
{
|
||||
title: 'Comprehensive SEO Scoring',
|
||||
description: 'Multi-dimensional SEO analysis across key factors',
|
||||
details: [
|
||||
'Overall SEO score (0-100)',
|
||||
'Structure optimization score',
|
||||
'Keyword optimization rating',
|
||||
'Readability assessment',
|
||||
'Quality metrics evaluation'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-scoring.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Actionable Recommendations',
|
||||
description: 'AI-powered improvement suggestions',
|
||||
details: [
|
||||
'Priority-ranked fixes',
|
||||
'Specific text improvements',
|
||||
'Keyword density optimization',
|
||||
'Heading structure suggestions',
|
||||
'Content enhancement ideas'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-recommendations.jpg'
|
||||
},
|
||||
{
|
||||
title: 'AI-Powered Content Refinement',
|
||||
description: 'Automatically apply SEO recommendations',
|
||||
details: [
|
||||
'Smart content rewriting',
|
||||
'Preserves original intent',
|
||||
'Natural keyword integration',
|
||||
'Readability improvement',
|
||||
'Structure optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-apply.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Keyword Analysis',
|
||||
description: 'Deep dive into keyword performance',
|
||||
details: [
|
||||
'Primary keyword density',
|
||||
'Semantic keyword usage',
|
||||
'Long-tail keyword opportunities',
|
||||
'Keyword distribution heatmap',
|
||||
'Optimization recommendations'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-keywords.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Parallel non-AI analyzers + single AI call',
|
||||
promptType: 'Structured SEO analysis prompt',
|
||||
outputFormat: 'Comprehensive SEO report with scores and recommendations',
|
||||
integration: 'BlogContentSEOAnalyzer with parallel processing'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase4-seo.mp4'
|
||||
},
|
||||
{
|
||||
id: 'metadata',
|
||||
name: 'SEO Metadata',
|
||||
icon: '🎯',
|
||||
shortDescription: 'Optimized metadata generation for titles, descriptions, Open Graph, Twitter cards, and structured data',
|
||||
features: [
|
||||
{
|
||||
title: 'Comprehensive Metadata',
|
||||
description: 'All-in-one SEO metadata generation',
|
||||
details: [
|
||||
'SEO-optimized title (50-60 chars)',
|
||||
'Meta description with CTA',
|
||||
'URL slug optimization',
|
||||
'Blog tags and categories',
|
||||
'Social hashtags'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-comprehensive.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Open Graph & Twitter Cards',
|
||||
description: 'Rich social media previews',
|
||||
details: [
|
||||
'OG title and description',
|
||||
'Twitter card optimization',
|
||||
'Image preview settings',
|
||||
'Social engagement boost',
|
||||
'Click-through optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-social.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Structured Data',
|
||||
description: 'Schema.org markup for rich snippets',
|
||||
details: [
|
||||
'Article schema',
|
||||
'Organization markup',
|
||||
'Breadcrumb schema',
|
||||
'FAQ schema support',
|
||||
'Enhanced search results'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-schema.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Multi-Format Output',
|
||||
description: 'Ready-to-use metadata in all formats',
|
||||
details: [
|
||||
'HTML meta tags',
|
||||
'JSON-LD structured data',
|
||||
'WordPress export format',
|
||||
'Wix integration format',
|
||||
'One-click copy options'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-export.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Maximum 2 AI calls for comprehensive metadata',
|
||||
promptType: 'Personalized metadata prompt with context',
|
||||
outputFormat: 'Complete metadata package (title, desc, tags, schema)',
|
||||
integration: 'BlogSEOMetadataGenerator with optimization'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase5-metadata.mp4'
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
name: 'Publish & Distribute',
|
||||
icon: '🚀',
|
||||
shortDescription: 'Direct publishing to WordPress, Wix, Medium, and other platforms with scheduling',
|
||||
features: [
|
||||
{
|
||||
title: 'Multi-Platform Publishing',
|
||||
description: 'Publish to multiple platforms simultaneously',
|
||||
details: [
|
||||
'WordPress direct publishing',
|
||||
'Wix blog integration',
|
||||
'Medium publishing',
|
||||
'Custom blog platforms',
|
||||
'API integrations'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-platforms.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Content Scheduling',
|
||||
description: 'Schedule posts for optimal timing',
|
||||
details: [
|
||||
'Time-based scheduling',
|
||||
'Timezone management',
|
||||
'Bulk scheduling support',
|
||||
'Calendar integration',
|
||||
'Reminder notifications'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-schedule.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Revision Management',
|
||||
description: 'Track and manage content versions',
|
||||
details: [
|
||||
'Version history',
|
||||
'Change tracking',
|
||||
'Rollback capabilities',
|
||||
'A/B testing support',
|
||||
'Performance comparison'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-versions.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Analytics Integration',
|
||||
description: 'Post-publish performance tracking',
|
||||
details: [
|
||||
'View count tracking',
|
||||
'Engagement metrics',
|
||||
'SEO performance',
|
||||
'Traffic analysis',
|
||||
'Conversion tracking'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-analytics.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Platform-specific API integrations',
|
||||
promptType: 'N/A - publishing only',
|
||||
outputFormat: 'Published content with URL',
|
||||
integration: 'Platform APIs via Publisher component'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase6-publish.mp4'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 8, bgcolor: 'background.paper' }}>
|
||||
<Container maxWidth="lg">
|
||||
{/* Section Title */}
|
||||
<Box sx={{ textAlign: 'center', mb: 6 }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
Complete AI Blog Writing Workflow
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: '800px', mx: 'auto' }}>
|
||||
Six powerful phases that transform your ideas into SEO-optimized, engaging blog content
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Phase Cards */}
|
||||
<Grid container spacing={4}>
|
||||
{phases.map((phase, index) => (
|
||||
<Grid item xs={12} md={6} key={phase.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: activePhase === index ? 2 : 1,
|
||||
borderColor: activePhase === index ? 'primary.main' : 'divider',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: 6,
|
||||
}
|
||||
}}
|
||||
onClick={() => setActivePhase(activePhase === index ? null : index)}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" mb={2}>
|
||||
<Typography variant="h2" sx={{ fontSize: '3rem' }}>
|
||||
{phase.icon}
|
||||
</Typography>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h5" fontWeight={600} gutterBottom>
|
||||
{phase.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{phase.shortDescription}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`Phase ${index + 1}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{activePhase === index && (
|
||||
<Box sx={{ mt: 3, pt: 3, borderTop: 1, borderColor: 'divider' }}>
|
||||
{/* Video Placeholder */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'grey.200',
|
||||
borderRadius: 2,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
🎥 Video: {phase.videoPlaceholder}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features Grid */}
|
||||
<Grid container spacing={2} mb={3}>
|
||||
{phase.features.map((feature, idx) => (
|
||||
<Grid item xs={12} sm={6} key={idx}>
|
||||
<Card variant="outlined" sx={{ p: 2, height: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '4/3',
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
📷 Image
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={1}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{feature.details.slice(0, 3).map((detail, i) => (
|
||||
<Stack key={i} direction="row" spacing={1} alignItems="flex-start">
|
||||
<CheckCircle sx={{ fontSize: 16, color: 'success.main', mt: 0.5 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{detail}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Technical Details */}
|
||||
<Card variant="outlined" sx={{ bgcolor: 'grey.50', p: 2 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600} mb={1} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AutoAwesome sx={{ fontSize: 18 }} />
|
||||
Technical Implementation
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>AI Model</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.aiModel}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Output Format</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.outputFormat}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Prompt Type</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.promptType}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Integration</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
{phase.technicalDetails.integration}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriterPhasesSection;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import BlogWriterLanding from '../BlogWriterLanding';
|
||||
import ManualResearchForm from '../ManualResearchForm';
|
||||
|
||||
interface BlogWriterLandingSectionProps {
|
||||
research: any;
|
||||
copilotKitAvailable: boolean;
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
onResearchComplete: (research: any) => void;
|
||||
}
|
||||
|
||||
export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> = ({
|
||||
research,
|
||||
copilotKitAvailable,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
onResearchComplete,
|
||||
}) => {
|
||||
// Only show landing/initial content when no research exists
|
||||
// Phase navigation header is always visible, so this is just the initial content
|
||||
if (!research) {
|
||||
return (
|
||||
<>
|
||||
{/* Show manual research form when on research phase and CopilotKit unavailable */}
|
||||
{!copilotKitAvailable && currentPhase === 'research' && (
|
||||
<ManualResearchForm onResearchComplete={onResearchComplete} />
|
||||
)}
|
||||
{/* Show landing page for CopilotKit flow or when not on research phase */}
|
||||
{(!copilotKitAvailable && currentPhase !== 'research') || copilotKitAvailable ? (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Navigate to research phase to start the workflow
|
||||
navigateToPhase('research');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import KeywordInputForm from '../KeywordInputForm';
|
||||
import ResearchAction from '../ResearchAction';
|
||||
import { CustomOutlineForm } from '../CustomOutlineForm';
|
||||
import { ResearchDataActions } from '../ResearchDataActions';
|
||||
import { EnhancedOutlineActions } from '../EnhancedOutlineActions';
|
||||
import OutlineFeedbackForm from '../OutlineFeedbackForm';
|
||||
import { RewriteFeedbackForm } from '../RewriteFeedbackForm';
|
||||
|
||||
interface CopilotKitComponentsProps {
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
sections: Record<string, string>;
|
||||
selectedTitle: string | null;
|
||||
onResearchComplete: (research: any) => void;
|
||||
onOutlineCreated: (outline: any[]) => void;
|
||||
onOutlineUpdated: (outline: any[]) => void;
|
||||
onTitleOptionsSet: (titles: any[]) => void;
|
||||
onOutlineConfirmed: () => void;
|
||||
onOutlineRefined: (feedback?: string) => void;
|
||||
onMediumGenerationStarted: (taskId: string) => void;
|
||||
onMediumGenerationTriggered: () => void;
|
||||
onRewriteStarted: (taskId: string) => void;
|
||||
onRewriteTriggered: () => void;
|
||||
setFlowAnalysisCompleted: (completed: boolean) => void;
|
||||
setFlowAnalysisResults: (results: any) => void;
|
||||
setContinuityRefresh: (refresh: number | ((prev: number) => number)) => void;
|
||||
researchPolling: any;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
sections,
|
||||
selectedTitle,
|
||||
onResearchComplete,
|
||||
onOutlineCreated,
|
||||
onOutlineUpdated,
|
||||
onTitleOptionsSet,
|
||||
onOutlineConfirmed,
|
||||
onOutlineRefined,
|
||||
onMediumGenerationStarted,
|
||||
onMediumGenerationTriggered,
|
||||
onRewriteStarted,
|
||||
onRewriteTriggered,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
setContinuityRefresh,
|
||||
researchPolling,
|
||||
navigateToPhase,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<KeywordInputForm
|
||||
onResearchComplete={onResearchComplete}
|
||||
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
|
||||
/>
|
||||
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
|
||||
<ResearchAction onResearchComplete={onResearchComplete} navigateToPhase={navigateToPhase} />
|
||||
|
||||
<ResearchDataActions
|
||||
research={research}
|
||||
onOutlineCreated={onOutlineCreated}
|
||||
onTitleOptionsSet={onTitleOptionsSet}
|
||||
navigateToPhase={navigateToPhase}
|
||||
/>
|
||||
<EnhancedOutlineActions
|
||||
outline={outline}
|
||||
onOutlineUpdated={onOutlineUpdated}
|
||||
/>
|
||||
<OutlineFeedbackForm
|
||||
outline={outline}
|
||||
research={research!}
|
||||
onOutlineConfirmed={onOutlineConfirmed}
|
||||
onOutlineRefined={onOutlineRefined}
|
||||
onMediumGenerationStarted={onMediumGenerationStarted}
|
||||
onMediumGenerationTriggered={onMediumGenerationTriggered}
|
||||
sections={sections}
|
||||
blogTitle={selectedTitle ?? undefined}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onFlowAnalysisComplete={(analysis) => {
|
||||
console.log('Flow analysis completed:', analysis);
|
||||
setFlowAnalysisCompleted(true);
|
||||
setFlowAnalysisResults(analysis);
|
||||
// Trigger a refresh of continuity badges
|
||||
setContinuityRefresh((prev: number) => (prev || 0) + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Rewrite Feedback Form - Only show when content exists */}
|
||||
{Object.keys(sections).length > 0 && (
|
||||
<RewriteFeedbackForm
|
||||
research={research!}
|
||||
outline={outline}
|
||||
sections={sections}
|
||||
blogTitle={selectedTitle || 'Untitled'}
|
||||
onRewriteStarted={onRewriteStarted}
|
||||
onRewriteTriggered={onRewriteTriggered}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import PhaseNavigation, { PhaseActionHandlers } from '../PhaseNavigation';
|
||||
|
||||
interface HeaderBarProps {
|
||||
phases: any[];
|
||||
currentPhase: string;
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
copilotKitAvailable?: boolean;
|
||||
actionHandlers?: PhaseActionHandlers;
|
||||
hasResearch?: boolean;
|
||||
hasOutline?: boolean;
|
||||
outlineConfirmed?: boolean;
|
||||
hasContent?: boolean;
|
||||
contentConfirmed?: boolean;
|
||||
hasSEOAnalysis?: boolean;
|
||||
seoRecommendationsApplied?: boolean;
|
||||
hasSEOMetadata?: boolean;
|
||||
}
|
||||
|
||||
export const HeaderBar: React.FC<HeaderBarProps> = ({
|
||||
phases,
|
||||
currentPhase,
|
||||
onPhaseClick,
|
||||
copilotKitAvailable = true,
|
||||
actionHandlers,
|
||||
hasResearch = false,
|
||||
hasOutline = false,
|
||||
outlineConfirmed = false,
|
||||
hasContent = false,
|
||||
contentConfirmed = false,
|
||||
hasSEOAnalysis = false,
|
||||
seoRecommendationsApplied = false,
|
||||
hasSEOMetadata = false,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666'
|
||||
}}>
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
<PhaseNavigation
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={onPhaseClick}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={actionHandlers}
|
||||
hasResearch={hasResearch}
|
||||
hasOutline={hasOutline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={hasContent}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={hasSEOAnalysis}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
hasSEOMetadata={hasSEOMetadata}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OutlineCtaBannerProps {
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
const OutlineCtaBanner: React.FC<OutlineCtaBannerProps> = ({ onGenerate }) => {
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', background: '#fff8e1', borderBottom: '1px solid #ffe0b2', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: '#8d6e63' }}>Next step: generate your outline from research.</span>
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
style={{ padding: '6px 10px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}
|
||||
>
|
||||
Next: Create Outline
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineCtaBanner;
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import ResearchResults from '../ResearchResults';
|
||||
import EnhancedTitleSelector from '../EnhancedTitleSelector';
|
||||
import EnhancedOutlineEditor from '../EnhancedOutlineEditor';
|
||||
import { BlogEditor } from '../WYSIWYG';
|
||||
import OutlineCtaBanner from './OutlineCtaBanner';
|
||||
import ManualResearchForm from '../ManualResearchForm';
|
||||
import ManualOutlineButton from '../ManualOutlineButton';
|
||||
import ManualContentButton from '../ManualContentButton';
|
||||
|
||||
interface PhaseContentProps {
|
||||
currentPhase: string;
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
titleOptions: any[];
|
||||
selectedTitle?: string | null;
|
||||
researchTitles: any[];
|
||||
aiGeneratedTitles: any[];
|
||||
sourceMappingStats: any;
|
||||
groundingInsights: any;
|
||||
optimizationResults: any;
|
||||
researchCoverage: any;
|
||||
setOutline: (o: any) => void;
|
||||
sections: Record<string, string>;
|
||||
handleContentUpdate: any;
|
||||
handleContentSave: any;
|
||||
continuityRefresh: number | null;
|
||||
flowAnalysisResults: any;
|
||||
outlineGenRef: React.RefObject<any>;
|
||||
blogWriterApi: any;
|
||||
contentConfirmed: boolean;
|
||||
seoAnalysis: any;
|
||||
seoMetadata: any;
|
||||
onTitleSelect: any;
|
||||
onCustomTitle: any;
|
||||
sectionImages?: Record<string, string>;
|
||||
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
|
||||
copilotKitAvailable?: boolean; // Whether CopilotKit is available
|
||||
onResearchComplete?: (research: any) => void; // Callback when research completes (for manual form)
|
||||
onOutlineGenerationStart?: (taskId: string) => void; // Callback when outline generation starts
|
||||
onContentGenerationStart?: (taskId: string) => void; // Callback when content generation starts
|
||||
}
|
||||
|
||||
export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
currentPhase,
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
setOutline,
|
||||
sections,
|
||||
handleContentUpdate,
|
||||
handleContentSave,
|
||||
continuityRefresh,
|
||||
flowAnalysisResults,
|
||||
outlineGenRef,
|
||||
blogWriterApi,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
onTitleSelect,
|
||||
onCustomTitle,
|
||||
sectionImages,
|
||||
setSectionImages,
|
||||
copilotKitAvailable = true,
|
||||
onResearchComplete,
|
||||
onOutlineGenerationStart,
|
||||
onContentGenerationStart,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{currentPhase === 'research' && (
|
||||
<>
|
||||
{research ? (
|
||||
<ResearchResults research={research} />
|
||||
) : (
|
||||
<>
|
||||
{copilotKitAvailable ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Start Your Research</h3>
|
||||
<p>Use the copilot to begin researching your blog topic.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ManualResearchForm onResearchComplete={onResearchComplete} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'outline' && research && (
|
||||
<>
|
||||
{outline.length === 0 && (
|
||||
<>
|
||||
{copilotKitAvailable ? (
|
||||
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
|
||||
) : (
|
||||
<ManualOutlineButton
|
||||
outlineGenRef={outlineGenRef}
|
||||
hasResearch={!!research}
|
||||
onGenerationStart={onOutlineGenerationStart}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{outline.length > 0 ? (
|
||||
<>
|
||||
<EnhancedTitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle || undefined}
|
||||
sections={outline}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={onTitleSelect}
|
||||
onCustomTitle={onCustomTitle}
|
||||
research={research}
|
||||
/>
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op: any, id: any, payload: any) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
sectionImages={sectionImages}
|
||||
setSectionImages={setSectionImages}
|
||||
/>
|
||||
</>
|
||||
) : !copilotKitAvailable ? (
|
||||
<ManualOutlineButton
|
||||
outlineGenRef={outlineGenRef}
|
||||
hasResearch={!!research}
|
||||
onGenerationStart={onOutlineGenerationStart}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Create Your Outline</h3>
|
||||
<p>Use the copilot to generate an outline based on your research.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'content' && outline.length > 0 && (
|
||||
<>
|
||||
{outlineConfirmed ? (
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImages={sectionImages}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{copilotKitAvailable ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Confirm Your Outline</h3>
|
||||
<p>Review and confirm your outline before generating content.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ManualContentButton
|
||||
outline={outline}
|
||||
research={research}
|
||||
blogTitle={selectedTitle || undefined}
|
||||
sections={sections}
|
||||
onGenerationStart={onContentGenerationStart}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
|
||||
<>
|
||||
{Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImages={sectionImages}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Loading Content...</h3>
|
||||
<p>Please wait while your content is being optimized.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback for SEO phase if conditions not met */}
|
||||
{currentPhase === 'seo' && (!contentConfirmed || outline.length === 0 || !outlineConfirmed) && (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Optimize your blog for search engines.</h3>
|
||||
<p>Complete the content phase first to enable SEO optimization.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Publish Your Blog</h3>
|
||||
<p>Your blog is ready to publish!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseContent;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { OutlineProgressModal } from '../OutlineProgressModal';
|
||||
|
||||
interface PollingState {
|
||||
isPolling: boolean;
|
||||
currentStatus: string;
|
||||
progressMessages: { message: string }[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface TaskProgressModalsProps {
|
||||
showOutlineModal: boolean;
|
||||
outlinePolling: PollingState;
|
||||
showModal: boolean;
|
||||
rewritePolling: PollingState;
|
||||
mediumPolling: PollingState;
|
||||
}
|
||||
|
||||
const TaskProgressModals: React.FC<TaskProgressModalsProps> = ({
|
||||
showOutlineModal,
|
||||
outlinePolling,
|
||||
showModal,
|
||||
rewritePolling,
|
||||
mediumPolling,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<OutlineProgressModal
|
||||
isVisible={showOutlineModal}
|
||||
status={outlinePolling.currentStatus}
|
||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
|
||||
error={outlinePolling.error ?? null}
|
||||
/>
|
||||
|
||||
<OutlineProgressModal
|
||||
isVisible={showModal}
|
||||
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
|
||||
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={rewritePolling.isPolling ? (
|
||||
rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : ''
|
||||
) : (
|
||||
mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''
|
||||
)}
|
||||
error={(rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error) ?? null}
|
||||
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProgressModals;
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { usePlatformConnections } from '../../../components/OnboardingWizard/common/usePlatformConnections';
|
||||
|
||||
interface WixConnectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConnectionSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConnectionSuccess
|
||||
}) => {
|
||||
const { handleConnect, isLoading } = usePlatformConnections();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// Handle OAuth success via postMessage (same pattern as onboarding)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
const trusted = [window.location.origin, 'https://littery-sonny-unscrutinisingly.ngrok-free.dev'];
|
||||
if (!trusted.includes(event.origin)) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_SUCCESS') {
|
||||
console.log('Wix OAuth success in modal');
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
// Close modal and notify parent
|
||||
if (onConnectionSuccess) {
|
||||
onConnectionSuccess();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_ERROR') {
|
||||
console.error('Wix OAuth error in modal:', event.data.error);
|
||||
setIsConnecting(false);
|
||||
setError(event.data.error || 'Wix connection failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, [isOpen, onClose, onConnectionSuccess]);
|
||||
|
||||
// Also check for URL param (fallback for same-tab redirect)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
console.log('Wix connected via URL param in modal');
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
if (onConnectionSuccess) {
|
||||
onConnectionSuccess();
|
||||
}
|
||||
onClose();
|
||||
// Clean URL
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean || '/');
|
||||
}
|
||||
}, [isOpen, onClose, onConnectionSuccess]);
|
||||
|
||||
const handleConnectClick = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
// Store current page URL so we can redirect back after OAuth completes
|
||||
// This MUST be stored before calling handleConnect to ensure it's available after redirect
|
||||
// We ALWAYS override any existing redirect URL since we know the exact page we're on (Blog Writer)
|
||||
// Build the redirect URL to ensure it includes the phase (publish) and works with both localhost and ngrok
|
||||
const currentPath = window.location.pathname;
|
||||
const currentHash = window.location.hash || '#publish'; // Default to publish phase if no hash
|
||||
const currentSearch = window.location.search;
|
||||
|
||||
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
|
||||
// This ensures consistency between where OAuth starts and where callback happens
|
||||
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
|
||||
const isUsingNgrok = window.location.origin.includes('localhost') ||
|
||||
window.location.origin.includes('127.0.0.1') ||
|
||||
window.location.origin === NGROK_ORIGIN;
|
||||
const redirectOrigin = isUsingNgrok ? NGROK_ORIGIN : window.location.origin;
|
||||
|
||||
// Build redirect URL with normalized origin
|
||||
const redirectUrl = `${redirectOrigin}${currentPath}${currentHash}${currentSearch}`;
|
||||
|
||||
try {
|
||||
// Always override any existing redirect URL when connecting from Blog Writer
|
||||
sessionStorage.setItem('wix_oauth_redirect', redirectUrl);
|
||||
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', {
|
||||
redirectUrl,
|
||||
currentOrigin: window.location.origin,
|
||||
redirectOrigin,
|
||||
isUsingNgrok
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[WixConnectModal] Failed to store redirect URL:', e);
|
||||
}
|
||||
await handleConnect('wix');
|
||||
// OAuth will redirect, so we don't need to do anything else here
|
||||
// The postMessage handler or URL param handler will close the modal
|
||||
} catch (err: any) {
|
||||
console.error('Error connecting to Wix:', err);
|
||||
setIsConnecting(false);
|
||||
setError(err?.message || 'Failed to start Wix connection. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Connect Your Wix Account
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ py: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Connect your Wix account to publish blog posts directly to your website.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isConnecting && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Opening Wix authorization page...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8fafc', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>What happens next:</strong>
|
||||
</Typography>
|
||||
<Typography variant="caption" component="div" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
<ol style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||
<li>You'll be redirected to Wix to authorize ALwrity</li>
|
||||
<li>Grant permissions for blog creation and publishing</li>
|
||||
<li>You'll be redirected back to ALwrity</li>
|
||||
<li>Your blog post will be published automatically</li>
|
||||
</ol>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={isConnecting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleConnectClick}
|
||||
disabled={isConnecting || isLoading}
|
||||
startIcon={isConnecting ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect to Wix'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default WixConnectModal;
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import React from 'react';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
|
||||
interface WriterCopilotSidebarProps {
|
||||
suggestions: any[];
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
}
|
||||
|
||||
export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
|
||||
suggestions,
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
/* Enterprise CopilotKit Suggestion Styling */
|
||||
|
||||
/* All suggestion chips - base styling */
|
||||
.copilotkit-suggestions button,
|
||||
.copilot-suggestions button,
|
||||
[class*="suggestion"] button,
|
||||
[class*="Suggestion"] button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
|
||||
color: #4b5563;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Shine effect on hover */
|
||||
.copilotkit-suggestions button::before,
|
||||
.copilot-suggestions button::before,
|
||||
[class*="suggestion"] button::before,
|
||||
[class*="Suggestion"] button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
.copilotkit-suggestions button:hover::before,
|
||||
.copilot-suggestions button:hover::before,
|
||||
[class*="suggestion"] button:hover::before,
|
||||
[class*="Suggestion"] button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Regular suggestions - hover effects */
|
||||
.copilotkit-suggestions button:hover,
|
||||
.copilot-suggestions button:hover,
|
||||
[class*="suggestion"] button:hover:not([class*="next-suggestion"]),
|
||||
[class*="Suggestion"] button:hover:not([class*="next-suggestion"]) {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(249, 250, 251, 1) 100%);
|
||||
}
|
||||
|
||||
/* "Next:" Suggestions - Premium Enterprise Style */
|
||||
.copilotkit-suggestions button[data-is-next="true"],
|
||||
.copilot-suggestions button[data-is-next="true"],
|
||||
.copilotkit-suggestions button.next-suggestion,
|
||||
.copilot-suggestions button.next-suggestion,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"],
|
||||
.copilot-suggestions button[aria-label*="Next:"] {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%) !important;
|
||||
color: white !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 20px rgba(102, 126, 234, 0.3) !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
animation: nextSuggestionPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Pulse animation for Next suggestions */
|
||||
@keyframes nextSuggestionPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 30px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Next suggestion hover - enhanced */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:hover,
|
||||
.copilot-suggestions button[data-is-next="true"]:hover,
|
||||
.copilotkit-suggestions button.next-suggestion:hover,
|
||||
.copilot-suggestions button.next-suggestion:hover,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:hover,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:hover {
|
||||
transform: translateY(-3px) scale(1.05) !important;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
0 3px 6px rgba(0, 0, 0, 0.15) inset,
|
||||
0 0 40px rgba(102, 126, 234, 0.6) !important;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 40%, #f093fb 60%, #4facfe 100%) !important;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Next suggestion active */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:active,
|
||||
.copilot-suggestions button[data-is-next="true"]:active,
|
||||
.copilotkit-suggestions button.next-suggestion:active,
|
||||
.copilot-suggestions button.next-suggestion:active,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:active,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:active {
|
||||
transform: translateY(-1px) scale(1.02) !important;
|
||||
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
|
||||
0 1px 3px rgba(0, 0, 0, 0.1) inset !important;
|
||||
}
|
||||
|
||||
/* Next suggestion focus */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:focus-visible,
|
||||
.copilot-suggestions button[data-is-next="true"]:focus-visible,
|
||||
.copilotkit-suggestions button.next-suggestion:focus-visible,
|
||||
.copilot-suggestions button.next-suggestion:focus-visible,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:focus-visible,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.4),
|
||||
0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 0 30px rgba(102, 126, 234, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Match buttons by text content using data attributes or class */
|
||||
/* We'll inject a data attribute via JS to identify Next suggestions */
|
||||
|
||||
/* Regular suggestion active state */
|
||||
.copilotkit-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion),
|
||||
.copilot-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion) {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* Focus states for regular suggestions */
|
||||
.copilotkit-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion),
|
||||
.copilot-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion) {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3), 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Enhanced suggestion container */
|
||||
.copilotkit-suggestions,
|
||||
.copilot-suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(249, 250, 251, 0.6) 100%);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@media (min-width: 420px) {
|
||||
.copilotkit-suggestions,
|
||||
.copilot-suggestions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Inject data attributes to identify Next suggestions */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
const observer = new MutationObserver(() => {
|
||||
const suggestionButtons = document.querySelectorAll(
|
||||
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
|
||||
);
|
||||
suggestionButtons.forEach(btn => {
|
||||
const text = btn.textContent || btn.innerText || '';
|
||||
if (text.includes('Next:')) {
|
||||
btn.setAttribute('data-is-next', 'true');
|
||||
btn.classList.add('next-suggestion');
|
||||
} else {
|
||||
btn.removeAttribute('data-is-next');
|
||||
btn.classList.remove('next-suggestion');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Initial run
|
||||
setTimeout(() => {
|
||||
const suggestionButtons = document.querySelectorAll(
|
||||
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
|
||||
);
|
||||
suggestionButtons.forEach(btn => {
|
||||
const text = btn.textContent || btn.innerText || '';
|
||||
if (text.includes('Next:')) {
|
||||
btn.setAttribute('data-is-next', 'true');
|
||||
btn.classList.add('next-suggestion');
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
})();
|
||||
`
|
||||
}}
|
||||
/>
|
||||
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
const hasResearch = research !== null && research !== undefined;
|
||||
const hasOutline = outline && outline.length > 0;
|
||||
const isOutlineConfirmed = outlineConfirmed;
|
||||
const researchInfo = hasResearch && research
|
||||
? {
|
||||
sources: research?.sources?.length || 0,
|
||||
queries: research?.search_queries?.length || 0,
|
||||
angles: research?.suggested_angles?.length || 0,
|
||||
primaryKeywords: research?.keyword_analysis?.primary || [],
|
||||
searchIntent: research?.keyword_analysis?.search_intent || 'informational',
|
||||
}
|
||||
: null;
|
||||
|
||||
const outlineContext = hasOutline && outline
|
||||
? `
|
||||
OUTLINE DETAILS:
|
||||
- Total sections: ${outline.length}
|
||||
- Section headings: ${(outline || []).map((s: any) => s?.heading || 'Untitled').join(', ')}
|
||||
- Total target words: ${(outline || []).reduce((sum: number, s: any) => sum + (s?.target_words || 0), 0)}
|
||||
- Section breakdown: ${(outline || [])
|
||||
.map(
|
||||
(s: any) => `${s?.heading || 'Untitled'} (${s?.target_words || 0} words, ${s?.subheadings?.length || 0} subheadings, ${s?.key_points?.length || 0} key points)`
|
||||
)
|
||||
.join('; ')}
|
||||
`
|
||||
: '';
|
||||
|
||||
const toolGuide = `
|
||||
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
|
||||
|
||||
CURRENT STATE:
|
||||
${hasResearch && researchInfo ? `
|
||||
✅ RESEARCH COMPLETED:
|
||||
- Found ${researchInfo.sources} sources with Google Search grounding
|
||||
- Generated ${researchInfo.queries} search queries
|
||||
- Created ${researchInfo.angles} content angles
|
||||
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline && outline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
|
||||
${outlineContext}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
|
||||
- researchTopic(keywords: string, industry?: string, target_audience?: string)
|
||||
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
|
||||
- generateOutline()
|
||||
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
|
||||
- refineOutline(prompt?: string) - Refine outline based on user feedback
|
||||
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
|
||||
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
||||
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
|
||||
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
|
||||
- generateSEOMetadata(title?: string)
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR & USER GUIDANCE:
|
||||
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
|
||||
- When user asks to research something, call getResearchKeywords() first to collect their keywords
|
||||
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
|
||||
|
||||
USER GUIDANCE STRATEGY:
|
||||
- If the user's last message EXACTLY matches an available tool name (e.g., generateOutline, confirmOutlineAndGenerateContent, confirmBlogContent, analyzeSEO), IMMEDIATELY call that tool with default arguments and WITHOUT any additional questions or confirmations
|
||||
- After research completion, ALWAYS guide user toward outline creation as the next step
|
||||
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
|
||||
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
|
||||
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
|
||||
- After outline generation, ALWAYS guide user to review and confirm the outline
|
||||
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
|
||||
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
|
||||
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
|
||||
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
|
||||
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
|
||||
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
|
||||
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
|
||||
- After content generation, guide users to review and confirm their content before moving to SEO stage
|
||||
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after content confirmation, show SEO analysis and publishing suggestions
|
||||
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
|
||||
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
|
||||
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
|
||||
|
||||
ENGAGEMENT TACTICS:
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
|
||||
- Use encouraging language and highlight progress made
|
||||
- If user seems lost, remind them of the current stage and suggest the next step
|
||||
- When research is complete, emphasize the value of the data found and guide to outline creation
|
||||
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
|
||||
- Encourage users to make small manual edits to the outline UI before using AI for major changes
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WriterCopilotSidebar;
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useRef } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
type ConfirmCb = () => string | Promise<string>;
|
||||
type AnalyzeCb = () => string | Promise<string>;
|
||||
type OpenMetadataCb = () => void;
|
||||
|
||||
interface UseBlogWriterCopilotActionsParams {
|
||||
isSEOAnalysisModalOpen: boolean;
|
||||
lastSEOModalOpenRef: React.MutableRefObject<number>;
|
||||
runSEOAnalysisDirect: AnalyzeCb;
|
||||
confirmBlogContent: ConfirmCb;
|
||||
sections: Record<string, string>;
|
||||
research: any;
|
||||
openSEOMetadata: OpenMetadataCb;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
// Consolidates all Copilot actions used by BlogWriter
|
||||
export function useBlogWriterCopilotActions({
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata,
|
||||
navigateToPhase,
|
||||
}: UseBlogWriterCopilotActionsParams) {
|
||||
// Maintain the same any-cast pattern for parity with component
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// confirmBlogContent
|
||||
useCopilotActionTyped({
|
||||
name: 'confirmBlogContent',
|
||||
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to SEO phase when content is confirmed
|
||||
navigateToPhase?.('seo');
|
||||
const msg = await confirmBlogContent();
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
// analyzeSEO
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeSEO',
|
||||
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to SEO phase when SEO analysis starts
|
||||
navigateToPhase?.('seo');
|
||||
|
||||
debug.log('[BlogWriter] SEO analysis action', {
|
||||
modalOpen: isSEOAnalysisModalOpen,
|
||||
hasSections: !!sections && Object.keys(sections).length > 0,
|
||||
hasResearch: !!research && !!(research as any)?.keyword_analysis,
|
||||
});
|
||||
const now = Date.now();
|
||||
if (isSEOAnalysisModalOpen || now - lastSEOModalOpenRef.current < 750) {
|
||||
return 'SEO analysis is already open.';
|
||||
}
|
||||
const msg = await runSEOAnalysisDirect();
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
// generateSEOMetadata
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
description: 'Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data',
|
||||
parameters: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
description: 'Optional blog title to use for metadata generation',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
// Navigate to SEO phase when SEO metadata generation starts
|
||||
navigateToPhase?.('seo');
|
||||
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
|
||||
}
|
||||
if (!research || !research.keyword_analysis) {
|
||||
return 'Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.';
|
||||
}
|
||||
openSEOMetadata();
|
||||
return 'Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default useBlogWriterCopilotActions;
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
useResearchPolling,
|
||||
useOutlinePolling,
|
||||
useMediumGenerationPolling,
|
||||
useRewritePolling,
|
||||
} from '../../../hooks/usePolling';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
|
||||
interface UseBlogWriterPollingProps {
|
||||
onResearchComplete: (research: any) => void;
|
||||
onOutlineComplete: (outline: any) => void;
|
||||
onOutlineError: (error: any) => void;
|
||||
onSectionsUpdate: (sections: Record<string, string>) => void;
|
||||
onContentConfirmed?: () => void; // Callback when content generation completes
|
||||
navigateToPhase?: (phase: string) => void; // Phase navigation function
|
||||
}
|
||||
|
||||
export const useBlogWriterPolling = ({
|
||||
onResearchComplete,
|
||||
onOutlineComplete,
|
||||
onOutlineError,
|
||||
onSectionsUpdate,
|
||||
onContentConfirmed,
|
||||
navigateToPhase,
|
||||
}: UseBlogWriterPollingProps) => {
|
||||
// Research polling hook (for context awareness)
|
||||
const researchPolling = useResearchPolling({
|
||||
onComplete: onResearchComplete,
|
||||
onError: (error) => console.error('Research polling error:', error)
|
||||
});
|
||||
|
||||
// Outline polling hook
|
||||
const outlinePolling = useOutlinePolling({
|
||||
onComplete: onOutlineComplete,
|
||||
onError: onOutlineError
|
||||
});
|
||||
|
||||
// Medium generation polling (used after confirm if short blog)
|
||||
const mediumPolling = useMediumGenerationPolling({
|
||||
onComplete: (result: any) => {
|
||||
try {
|
||||
if (result && result.sections) {
|
||||
const newSections: Record<string, string> = {};
|
||||
result.sections.forEach((s: any) => {
|
||||
newSections[String(s.id)] = s.content || '';
|
||||
});
|
||||
onSectionsUpdate(newSections);
|
||||
|
||||
// Cache the generated content (shared utility)
|
||||
if (Object.keys(newSections).length > 0) {
|
||||
const sectionIds = Object.keys(newSections);
|
||||
blogWriterCache.cacheContent(newSections, sectionIds);
|
||||
|
||||
// Auto-confirm content and navigate to SEO phase when content generation completes
|
||||
// This happens when user clicks "Next:Confirm and generate content"
|
||||
if (onContentConfirmed) {
|
||||
onContentConfirmed();
|
||||
}
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('seo');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply medium generation result:', e);
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error('Medium generation failed:', err)
|
||||
});
|
||||
|
||||
// Rewrite polling hook (used for blog rewrite operations)
|
||||
const rewritePolling = useRewritePolling({
|
||||
onComplete: (result: any) => {
|
||||
try {
|
||||
if (result && result.sections) {
|
||||
const newSections: Record<string, string> = {};
|
||||
result.sections.forEach((s: any) => {
|
||||
newSections[String(s.id)] = s.content || '';
|
||||
});
|
||||
onSectionsUpdate(newSections);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply rewrite result:', e);
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error('Rewrite failed:', err)
|
||||
});
|
||||
|
||||
// Memoize polling state objects to prevent unnecessary recalculations
|
||||
const researchPollingState = React.useMemo(
|
||||
() => ({ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus }),
|
||||
[researchPolling.isPolling, researchPolling.currentStatus]
|
||||
);
|
||||
const outlinePollingState = React.useMemo(
|
||||
() => ({ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus }),
|
||||
[outlinePolling.isPolling, outlinePolling.currentStatus]
|
||||
);
|
||||
const mediumPollingState = React.useMemo(
|
||||
() => ({ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus }),
|
||||
[mediumPolling.isPolling, mediumPolling.currentStatus]
|
||||
);
|
||||
|
||||
return {
|
||||
researchPolling,
|
||||
outlinePolling,
|
||||
mediumPolling,
|
||||
rewritePolling,
|
||||
researchPollingState,
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface UseBlogWriterRefsProps {
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
contentConfirmed: boolean;
|
||||
sections: Record<string, string>;
|
||||
currentPhase: string;
|
||||
isSEOAnalysisModalOpen: boolean;
|
||||
resetUserSelection: () => void;
|
||||
}
|
||||
|
||||
export const useBlogWriterRefs = ({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
currentPhase,
|
||||
isSEOAnalysisModalOpen,
|
||||
resetUserSelection,
|
||||
}: UseBlogWriterRefsProps) => {
|
||||
// Track when outlines/content become available for the first time
|
||||
const prevOutlineLenRef = useRef<number>(outline.length);
|
||||
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
|
||||
const prevContentConfirmedRef = useRef<boolean>(contentConfirmed);
|
||||
|
||||
useEffect(() => {
|
||||
const prevLen = prevOutlineLenRef.current;
|
||||
if (research && prevLen === 0 && outline.length > 0) {
|
||||
resetUserSelection();
|
||||
}
|
||||
prevOutlineLenRef.current = outline.length;
|
||||
}, [research, outline.length, resetUserSelection]);
|
||||
|
||||
// Only reset user selection when transitioning from not-confirmed to confirmed
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevOutlineConfirmedRef.current;
|
||||
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
|
||||
resetUserSelection(); // Allow auto-progression to content phase
|
||||
}
|
||||
prevOutlineConfirmedRef.current = outlineConfirmed;
|
||||
}, [outlineConfirmed, sections, resetUserSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevContentConfirmedRef.current;
|
||||
if (!wasConfirmed && contentConfirmed) {
|
||||
resetUserSelection(); // Allow auto-progression to SEO phase
|
||||
}
|
||||
prevContentConfirmedRef.current = contentConfirmed;
|
||||
}, [contentConfirmed, resetUserSelection]);
|
||||
|
||||
// Log critical state changes only (reduce noise)
|
||||
const lastPhaseRef = useRef<string>('');
|
||||
const lastSeoOpenRef = useRef<boolean>(false);
|
||||
const lastSectionsLenRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPhase !== lastPhaseRef.current) {
|
||||
debug.log('[BlogWriter] Phase changed', { currentPhase });
|
||||
lastPhaseRef.current = currentPhase;
|
||||
}
|
||||
}, [currentPhase]);
|
||||
|
||||
useEffect(() => {
|
||||
const open = isSEOAnalysisModalOpen;
|
||||
if (open !== lastSeoOpenRef.current) {
|
||||
debug.log('[BlogWriter] SEO modal', { isOpen: open });
|
||||
lastSeoOpenRef.current = open;
|
||||
}
|
||||
}, [isSEOAnalysisModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const len = Object.keys(sections || {}).length;
|
||||
if (len !== lastSectionsLenRef.current) {
|
||||
debug.log('[BlogWriter] Sections updated', { count: len });
|
||||
lastSectionsLenRef.current = len;
|
||||
}
|
||||
}, [sections]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useRef, useEffect, useMemo } from 'react';
|
||||
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { useSuggestions } from '../SuggestionsGenerator';
|
||||
|
||||
interface UseCopilotSuggestionsProps {
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
researchPollingState: { isPolling: boolean; currentStatus: any };
|
||||
outlinePollingState: { isPolling: boolean; currentStatus: any };
|
||||
mediumPollingState: { isPolling: boolean; currentStatus: any };
|
||||
hasContent: boolean;
|
||||
flowAnalysisCompleted: boolean;
|
||||
contentConfirmed: boolean;
|
||||
seoAnalysis: any;
|
||||
seoMetadata: any;
|
||||
seoRecommendationsApplied: boolean;
|
||||
}
|
||||
|
||||
export const useCopilotSuggestions = ({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPollingState,
|
||||
outlinePollingState,
|
||||
mediumPollingState,
|
||||
hasContent,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied,
|
||||
}: UseCopilotSuggestionsProps) => {
|
||||
const suggestions = useSuggestions({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPolling: researchPollingState,
|
||||
outlinePolling: outlinePollingState,
|
||||
mediumPolling: mediumPollingState,
|
||||
hasContent,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied,
|
||||
});
|
||||
|
||||
// Drive CopilotKit suggestions programmatically
|
||||
const copilotHeadless = (useCopilotChatHeadless_c as any)?.();
|
||||
const setSuggestionsRef = useRef<any>(null);
|
||||
useEffect(() => {
|
||||
setSuggestionsRef.current = copilotHeadless?.setSuggestions;
|
||||
}, [copilotHeadless]);
|
||||
|
||||
const suggestionsPayload = useMemo(
|
||||
() => (Array.isArray(suggestions) ? suggestions.map((s: any) => ({ title: s.title, message: s.message })) : []),
|
||||
[suggestions]
|
||||
);
|
||||
const prevSuggestionsRef = useRef<string>("__init__");
|
||||
const suggestionsJson = useMemo(() => JSON.stringify(suggestionsPayload), [suggestionsPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!setSuggestionsRef.current) return;
|
||||
if (suggestionsJson !== prevSuggestionsRef.current) {
|
||||
setSuggestionsRef.current(suggestionsPayload);
|
||||
debug.log('[BlogWriter] Copilot suggestions pushed', { count: suggestionsPayload.length });
|
||||
prevSuggestionsRef.current = suggestionsJson;
|
||||
}
|
||||
} catch {}
|
||||
}, [suggestionsJson, suggestionsPayload]);
|
||||
|
||||
// Force-sync Copilot suggestions right after SEO recommendations applied
|
||||
useEffect(() => {
|
||||
if (!seoAnalysis || !seoRecommendationsApplied || !setSuggestionsRef.current) return;
|
||||
try {
|
||||
if (suggestionsJson !== prevSuggestionsRef.current) {
|
||||
setSuggestionsRef.current(suggestionsPayload);
|
||||
debug.log('[BlogWriter] Forced Copilot suggestions sync after SEO recommendations applied', { count: suggestionsPayload.length });
|
||||
prevSuggestionsRef.current = suggestionsJson;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to push Copilot suggestions after SEO apply:', e);
|
||||
}
|
||||
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
setSuggestionsRef,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface UseModalVisibilityProps {
|
||||
mediumPolling: { isPolling: boolean };
|
||||
rewritePolling: { isPolling: boolean };
|
||||
outlinePolling: { isPolling: boolean };
|
||||
}
|
||||
|
||||
export const useModalVisibility = ({
|
||||
mediumPolling,
|
||||
rewritePolling,
|
||||
outlinePolling,
|
||||
}: UseModalVisibilityProps) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
|
||||
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
|
||||
const [showOutlineModal, setShowOutlineModal] = useState(false);
|
||||
|
||||
// Add minimum display time for modal
|
||||
useEffect(() => {
|
||||
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
||||
setShowModal(true);
|
||||
setModalStartTime(Date.now());
|
||||
} else if (!mediumPolling.isPolling && !rewritePolling.isPolling && !isMediumGenerationStarting && showModal) {
|
||||
const elapsed = Date.now() - (modalStartTime || 0);
|
||||
const minDisplayTime = 2000; // 2 seconds minimum
|
||||
|
||||
if (elapsed < minDisplayTime) {
|
||||
setTimeout(() => {
|
||||
setShowModal(false);
|
||||
setModalStartTime(null);
|
||||
}, minDisplayTime - elapsed);
|
||||
} else {
|
||||
setShowModal(false);
|
||||
setModalStartTime(null);
|
||||
}
|
||||
}
|
||||
}, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
|
||||
|
||||
// Handle outline modal visibility
|
||||
useEffect(() => {
|
||||
if (outlinePolling.isPolling && !showOutlineModal) {
|
||||
setShowOutlineModal(true);
|
||||
} else if (!outlinePolling.isPolling && showOutlineModal) {
|
||||
// Add a small delay to ensure user sees completion message
|
||||
setTimeout(() => {
|
||||
setShowOutlineModal(false);
|
||||
}, 1000);
|
||||
}
|
||||
}, [outlinePolling.isPolling, showOutlineModal]);
|
||||
|
||||
return {
|
||||
showModal,
|
||||
setShowModal,
|
||||
showOutlineModal,
|
||||
setShowOutlineModal,
|
||||
isMediumGenerationStarting,
|
||||
setIsMediumGenerationStarting,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { mediumBlogApi } from '../../../services/blogWriterApi';
|
||||
import { researchCache } from '../../../services/researchCache';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
|
||||
interface UsePhaseActionHandlersProps {
|
||||
research: any;
|
||||
outline: any[];
|
||||
selectedTitle: string | null;
|
||||
contentConfirmed: boolean;
|
||||
sections: Record<string, string>;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
handleOutlineConfirmed: () => void;
|
||||
setIsMediumGenerationStarting: (starting: boolean) => void;
|
||||
mediumPolling: any;
|
||||
outlineGenRef: React.RefObject<any>;
|
||||
setOutline: (outline: any[]) => void;
|
||||
setContentConfirmed: (confirmed: boolean) => void;
|
||||
setIsSEOAnalysisModalOpen: (open: boolean) => void;
|
||||
setIsSEOMetadataModalOpen: (open: boolean) => void;
|
||||
runSEOAnalysisDirect: () => string;
|
||||
onOutlineComplete?: (outline: any) => void;
|
||||
onContentComplete?: (sections: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export const usePhaseActionHandlers = ({
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
sections,
|
||||
navigateToPhase,
|
||||
handleOutlineConfirmed,
|
||||
setIsMediumGenerationStarting,
|
||||
mediumPolling,
|
||||
outlineGenRef,
|
||||
setOutline,
|
||||
setContentConfirmed,
|
||||
setIsSEOAnalysisModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
runSEOAnalysisDirect,
|
||||
onOutlineComplete,
|
||||
onContentComplete,
|
||||
}: UsePhaseActionHandlersProps) => {
|
||||
const handleResearchAction = useCallback(() => {
|
||||
navigateToPhase('research');
|
||||
debug.log('[BlogWriter] Research action triggered - navigating to research phase');
|
||||
// Note: Research caching is handled by ManualResearchForm component
|
||||
}, [navigateToPhase]);
|
||||
|
||||
const handleOutlineAction = useCallback(async () => {
|
||||
if (!research) {
|
||||
alert('Please complete research first before generating an outline.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||
|
||||
if (cachedOutline) {
|
||||
debug.log('[BlogWriter] Using cached outline from localStorage', { sections: cachedOutline.outline.length });
|
||||
setOutline(cachedOutline.outline);
|
||||
if (onOutlineComplete) {
|
||||
onOutlineComplete({ outline: cachedOutline.outline, title_options: cachedOutline.title_options });
|
||||
}
|
||||
navigateToPhase('outline');
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToPhase('outline');
|
||||
if (outlineGenRef.current) {
|
||||
try {
|
||||
const result = await outlineGenRef.current.generateNow();
|
||||
if (!result.success) {
|
||||
alert(result.message || 'Failed to generate outline');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Outline generation failed:', error);
|
||||
alert(`Outline generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
debug.log('[BlogWriter] Outline action triggered');
|
||||
}, [research, navigateToPhase, outlineGenRef, setOutline, onOutlineComplete]);
|
||||
|
||||
const handleContentAction = useCallback(async () => {
|
||||
if (!outline || outline.length === 0) {
|
||||
alert('Please generate and confirm an outline first.');
|
||||
return;
|
||||
}
|
||||
if (!research) {
|
||||
alert('Research data is required for content generation.');
|
||||
return;
|
||||
}
|
||||
navigateToPhase('content');
|
||||
|
||||
// Confirm outline first
|
||||
handleOutlineConfirmed();
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||
|
||||
if (cachedContent) {
|
||||
debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length });
|
||||
if (onContentComplete) {
|
||||
onContentComplete(cachedContent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check if sections already exist in current state (shared utility)
|
||||
if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) {
|
||||
debug.log('[BlogWriter] Content already exists in state, skipping generation', { sections: Object.keys(sections || {}).length });
|
||||
return;
|
||||
}
|
||||
|
||||
// If short/medium blog (<=1000 words), trigger content generation automatically
|
||||
const target = Number(
|
||||
research?.keyword_analysis?.blog_length ||
|
||||
(research as any)?.word_count_target ||
|
||||
localStorage.getItem('blog_length_target') ||
|
||||
0
|
||||
);
|
||||
|
||||
if (target && target <= 1000) {
|
||||
try {
|
||||
setIsMediumGenerationStarting(true);
|
||||
const payload = {
|
||||
title: selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || outline[0]?.heading || 'Untitled',
|
||||
sections: outline.map(s => ({
|
||||
id: s.id,
|
||||
heading: s.heading,
|
||||
keyPoints: s.key_points,
|
||||
subheadings: s.subheadings,
|
||||
keywords: s.keywords,
|
||||
targetWords: s.target_words,
|
||||
references: s.references,
|
||||
})),
|
||||
globalTargetWords: target,
|
||||
researchKeywords: research.original_keywords || research.keyword_analysis?.primary || [],
|
||||
};
|
||||
|
||||
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
|
||||
setIsMediumGenerationStarting(false);
|
||||
mediumPolling.startPolling(task_id);
|
||||
debug.log('[BlogWriter] Content action triggered - medium generation started', { task_id });
|
||||
} catch (error) {
|
||||
console.error('Content generation failed:', error);
|
||||
setIsMediumGenerationStarting(false);
|
||||
alert(`Content generation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
} else {
|
||||
// For longer blogs, just confirm outline - user will use manual button
|
||||
debug.log('[BlogWriter] Content action triggered - outline confirmed (manual content generation required)');
|
||||
}
|
||||
}, [outline, research, selectedTitle, sections, navigateToPhase, handleOutlineConfirmed, setIsMediumGenerationStarting, mediumPolling, onContentComplete]);
|
||||
|
||||
const handleSEOAction = useCallback(() => {
|
||||
if (!contentConfirmed) {
|
||||
// Mark content as confirmed when SEO action is clicked
|
||||
setContentConfirmed(true);
|
||||
}
|
||||
navigateToPhase('seo');
|
||||
runSEOAnalysisDirect();
|
||||
debug.log('[BlogWriter] SEO action triggered - running SEO analysis');
|
||||
}, [contentConfirmed, setContentConfirmed, navigateToPhase, runSEOAnalysisDirect]);
|
||||
|
||||
const handleApplySEORecommendations = useCallback(() => {
|
||||
navigateToPhase('seo');
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] Apply SEO Recommendations action triggered - opening SEO analysis modal');
|
||||
}, [navigateToPhase, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
const handlePublishAction = useCallback(() => {
|
||||
// Can be called from SEO phase (after recommendations applied) or publish phase
|
||||
navigateToPhase('seo'); // Stay in SEO phase if called from there
|
||||
setIsSEOMetadataModalOpen(true);
|
||||
debug.log('[BlogWriter] Generate SEO Metadata action triggered - opening SEO metadata modal');
|
||||
}, [navigateToPhase, setIsSEOMetadataModalOpen]);
|
||||
|
||||
return {
|
||||
handleResearchAction,
|
||||
handleOutlineAction,
|
||||
handleContentAction,
|
||||
handleSEOAction,
|
||||
handleApplySEORecommendations,
|
||||
handlePublishAction,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface UsePhaseRestorationProps {
|
||||
copilotKitAvailable: boolean;
|
||||
research: any;
|
||||
phases: any[];
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
setCurrentPhase: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const usePhaseRestoration = ({
|
||||
copilotKitAvailable,
|
||||
research,
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
}: UsePhaseRestorationProps) => {
|
||||
// When CopilotKit is unavailable and there's no research, ensure we're on research phase
|
||||
useEffect(() => {
|
||||
if (!copilotKitAvailable && !research && phases.length > 0 && currentPhase !== 'research') {
|
||||
navigateToPhase('research');
|
||||
debug.log('[BlogWriter] Auto-navigating to research phase (CopilotKit unavailable)');
|
||||
}
|
||||
}, [copilotKitAvailable, research, phases.length, currentPhase, navigateToPhase]);
|
||||
|
||||
// Restore phase from navigation state on mount (after subscription renewal)
|
||||
// Note: The PricingPage restores the phase to localStorage before redirecting
|
||||
// This effect ensures the phase is applied when BlogWriter loads
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Wait for phases to be initialized
|
||||
if (phases.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we just returned from pricing page (has restored phase in localStorage)
|
||||
const restoredPhase = localStorage.getItem('blogwriter_current_phase');
|
||||
const userSelectedPhase = localStorage.getItem('blogwriter_user_selected_phase') === 'true';
|
||||
|
||||
// Only restore if:
|
||||
// 1. A phase was saved (restoredPhase exists)
|
||||
// 2. User had manually selected a phase (indicates they were actively working)
|
||||
// 3. The phase is different from current (to avoid unnecessary updates)
|
||||
if (restoredPhase && userSelectedPhase && restoredPhase !== currentPhase) {
|
||||
const targetPhase = phases.find(p => p.id === restoredPhase);
|
||||
if (targetPhase && !targetPhase.disabled) {
|
||||
console.log('[BlogWriter] Restoring phase from navigation state:', restoredPhase);
|
||||
setCurrentPhase(restoredPhase);
|
||||
// Phase restoration complete - the usePhaseNavigation hook will handle persistence
|
||||
} else {
|
||||
console.log('[BlogWriter] Restored phase is disabled or not found, keeping current phase:', {
|
||||
restoredPhase,
|
||||
currentPhase,
|
||||
targetPhaseExists: !!targetPhase,
|
||||
targetPhaseDisabled: targetPhase?.disabled
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BlogWriter] Failed to restore phase from navigation state:', error);
|
||||
}
|
||||
}, [phases, currentPhase, setCurrentPhase]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
|
||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||
|
||||
const registerContentKey = (map: Map<string, string>, key: any, content?: string) => {
|
||||
if (key === undefined || key === null) {
|
||||
return;
|
||||
}
|
||||
const trimmed = String(key).trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const safeContent = content !== undefined && content !== null ? String(content) : '';
|
||||
map.set(trimmed, safeContent);
|
||||
map.set(trimmed.toLowerCase(), safeContent);
|
||||
};
|
||||
|
||||
const getIdCandidatesForSection = (section: any, index: number): string[] => {
|
||||
const rawCandidates = [
|
||||
section?.id,
|
||||
section?.section_id,
|
||||
section?.sectionId,
|
||||
section?.sectionID,
|
||||
section?.heading_id,
|
||||
`section_${index + 1}`,
|
||||
`Section ${index + 1}`,
|
||||
`section${index + 1}`,
|
||||
`s${index + 1}`,
|
||||
`S${index + 1}`,
|
||||
`${index + 1}`,
|
||||
];
|
||||
|
||||
const normalized = rawCandidates
|
||||
.map((value) => (value === undefined || value === null ? '' : String(value).trim()))
|
||||
.filter(Boolean);
|
||||
|
||||
return Array.from(new Set(normalized));
|
||||
};
|
||||
|
||||
const buildExistingContentMap = (sectionsRecord: Record<string, string>): Map<string, string> => {
|
||||
const map = new Map<string, string>();
|
||||
if (!sectionsRecord) {
|
||||
return map;
|
||||
}
|
||||
Object.entries(sectionsRecord).forEach(([key, value]) => {
|
||||
registerContentKey(map, key, value ?? '');
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const buildResponseContentMaps = (responseSections: any[]): { byId: Map<string, string>; byHeading: Map<string, string> } => {
|
||||
const byId = new Map<string, string>();
|
||||
const byHeading = new Map<string, string>();
|
||||
|
||||
if (!responseSections) {
|
||||
return { byId, byHeading };
|
||||
}
|
||||
|
||||
responseSections.forEach((section, index) => {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
const content = section?.content;
|
||||
const normalizedContent = content !== undefined && content !== null ? String(content).trim() : '';
|
||||
if (!normalizedContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
registerContentKey(byId, section?.id, normalizedContent);
|
||||
registerContentKey(byId, section?.section_id, normalizedContent);
|
||||
registerContentKey(byId, section?.sectionId, normalizedContent);
|
||||
registerContentKey(byId, section?.sectionID, normalizedContent);
|
||||
registerContentKey(byId, `section_${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `Section ${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `section${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `s${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `S${index + 1}`, normalizedContent);
|
||||
registerContentKey(byId, `${index + 1}`, normalizedContent);
|
||||
|
||||
const heading = section?.heading || section?.title;
|
||||
if (heading) {
|
||||
registerContentKey(byHeading, heading, normalizedContent);
|
||||
}
|
||||
});
|
||||
|
||||
return { byId, byHeading };
|
||||
};
|
||||
|
||||
const getPrimaryKeyForOutlineSection = (outlineSection: any, index: number): string => {
|
||||
const candidates = getIdCandidatesForSection(outlineSection, index);
|
||||
if (candidates.length > 0) {
|
||||
return candidates[0];
|
||||
}
|
||||
const fallbackHeading = outlineSection?.heading || outlineSection?.title;
|
||||
if (fallbackHeading) {
|
||||
const trimmed = String(fallbackHeading).trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return `section_${index + 1}`;
|
||||
};
|
||||
|
||||
const resolveContentForOutlineSection = (
|
||||
outlineSection: any,
|
||||
index: number,
|
||||
responseSections: any[],
|
||||
responseById: Map<string, string>,
|
||||
responseByHeading: Map<string, string>,
|
||||
existingContentMap: Map<string, string>
|
||||
): { content: string; matchedKey: string } => {
|
||||
const idCandidates = getIdCandidatesForSection(outlineSection, index);
|
||||
|
||||
for (const candidate of idCandidates) {
|
||||
if (responseById.has(candidate)) {
|
||||
return { content: responseById.get(candidate) || '', matchedKey: candidate };
|
||||
}
|
||||
const lower = candidate.toLowerCase();
|
||||
if (responseById.has(lower)) {
|
||||
return { content: responseById.get(lower) || '', matchedKey: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
const heading = outlineSection?.heading || outlineSection?.title;
|
||||
if (heading) {
|
||||
const headingKey = String(heading).trim();
|
||||
if (headingKey) {
|
||||
const lowerHeading = headingKey.toLowerCase();
|
||||
if (responseByHeading.has(lowerHeading)) {
|
||||
return { content: responseByHeading.get(lowerHeading) || '', matchedKey: headingKey };
|
||||
}
|
||||
if (responseByHeading.has(headingKey)) {
|
||||
return { content: responseByHeading.get(headingKey) || '', matchedKey: headingKey };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const responseSection = responseSections?.[index];
|
||||
if (responseSection?.content) {
|
||||
const normalizedContent = String(responseSection.content).trim();
|
||||
if (normalizedContent) {
|
||||
return {
|
||||
content: normalizedContent,
|
||||
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of idCandidates) {
|
||||
if (existingContentMap.has(candidate)) {
|
||||
return { content: existingContentMap.get(candidate) || '', matchedKey: candidate };
|
||||
}
|
||||
const lower = candidate.toLowerCase();
|
||||
if (existingContentMap.has(lower)) {
|
||||
return { content: existingContentMap.get(lower) || '', matchedKey: candidate };
|
||||
}
|
||||
}
|
||||
|
||||
if (heading) {
|
||||
const headingKey = String(heading).trim();
|
||||
if (headingKey) {
|
||||
const lowerHeading = headingKey.toLowerCase();
|
||||
if (existingContentMap.has(lowerHeading)) {
|
||||
return { content: existingContentMap.get(lowerHeading) || '', matchedKey: headingKey };
|
||||
}
|
||||
if (existingContentMap.has(headingKey)) {
|
||||
return { content: existingContentMap.get(headingKey) || '', matchedKey: headingKey };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: '',
|
||||
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
|
||||
};
|
||||
};
|
||||
|
||||
interface UseSEOManagerProps {
|
||||
sections: Record<string, string>;
|
||||
research: any;
|
||||
outline: any[];
|
||||
selectedTitle: string | null;
|
||||
contentConfirmed: boolean;
|
||||
seoAnalysis: any;
|
||||
currentPhase: string;
|
||||
navigateToPhase: (phase: string) => void;
|
||||
setContentConfirmed: (confirmed: boolean) => void;
|
||||
setSeoAnalysis: (analysis: any) => void;
|
||||
setSeoMetadata: (metadata: any) => void;
|
||||
setSections: (sections: Record<string, string>) => void;
|
||||
setSelectedTitle: (title: string | null) => void;
|
||||
setContinuityRefresh: (timestamp: number) => void;
|
||||
setFlowAnalysisCompleted: (completed: boolean) => void;
|
||||
setFlowAnalysisResults: (results: any) => void;
|
||||
}
|
||||
|
||||
export const useSEOManager = ({
|
||||
sections,
|
||||
research,
|
||||
outline,
|
||||
selectedTitle,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setContentConfirmed,
|
||||
setSeoAnalysis,
|
||||
setSeoMetadata,
|
||||
setSections,
|
||||
setSelectedTitle,
|
||||
setContinuityRefresh,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
}: UseSEOManagerProps) => {
|
||||
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
|
||||
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
|
||||
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
|
||||
const lastSEOModalOpenRef = useRef<number>(0);
|
||||
|
||||
// Helper: run same checks as analyzeSEO and open modal
|
||||
const runSEOAnalysisDirect = useCallback((): string => {
|
||||
const hasSections = !!sections && Object.keys(sections).length > 0;
|
||||
// Check if sections have actual content (not just empty strings)
|
||||
let sectionsWithContent = hasSections ? Object.values(sections).filter(c => c && c.trim().length > 0) : [];
|
||||
let hasValidContent = sectionsWithContent.length > 0;
|
||||
|
||||
// If sections don't exist in state, check cache (similar to how content generation checks cache)
|
||||
if (!hasValidContent && outline && outline.length > 0) {
|
||||
try {
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||
if (cachedContent && Object.keys(cachedContent).length > 0) {
|
||||
sectionsWithContent = Object.values(cachedContent).filter(c => c && c.trim().length > 0);
|
||||
hasValidContent = sectionsWithContent.length > 0;
|
||||
if (hasValidContent) {
|
||||
debug.log('[BlogWriter] Using cached content for SEO analysis', { sections: Object.keys(cachedContent).length });
|
||||
// Update sections state with cached content
|
||||
setSections(cachedContent);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug.log('[BlogWriter] Error checking cache for SEO analysis', e);
|
||||
}
|
||||
}
|
||||
|
||||
const hasResearch = !!research && !!(research as any).keyword_analysis;
|
||||
|
||||
if (!hasValidContent) {
|
||||
return "No blog content available for SEO analysis. Please generate content first. Content generation may still be in progress - please wait for it to complete.";
|
||||
}
|
||||
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
|
||||
// Prevent rapid re-opens
|
||||
const now = Date.now();
|
||||
if (isSEOAnalysisModalOpen && now - lastSEOModalOpenRef.current < 1000) {
|
||||
return "SEO analysis is already open.";
|
||||
}
|
||||
|
||||
// Mark content phase as done when user clicks "Next: Run SEO Analysis"
|
||||
if (!contentConfirmed) {
|
||||
setContentConfirmed(true);
|
||||
debug.log('[BlogWriter] Content phase marked as done (SEO analysis triggered)');
|
||||
}
|
||||
|
||||
setSeoRecommendationsApplied(false);
|
||||
if (!isSEOAnalysisModalOpen) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
lastSEOModalOpenRef.current = now;
|
||||
debug.log('[BlogWriter] SEO modal opened (direct)');
|
||||
}
|
||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
||||
}, [sections, research, outline, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed, setSections]);
|
||||
|
||||
const handleApplySeoRecommendations = useCallback(async (
|
||||
recommendations: BlogSEOActionableRecommendation[]
|
||||
) => {
|
||||
if (!outline || outline.length === 0) {
|
||||
throw new Error('An outline is required before applying recommendations.');
|
||||
}
|
||||
|
||||
const existingContentMap = buildExistingContentMap(sections || {});
|
||||
const emptyMap = new Map<string, string>();
|
||||
|
||||
const sectionPayload = outline.map((section, index) => {
|
||||
const existingMatch = resolveContentForOutlineSection(
|
||||
section,
|
||||
index,
|
||||
[],
|
||||
emptyMap,
|
||||
emptyMap,
|
||||
existingContentMap
|
||||
);
|
||||
const payloadContentRaw = existingMatch.content ?? sections?.[section?.id] ?? '';
|
||||
const payloadContent = payloadContentRaw !== undefined && payloadContentRaw !== null ? String(payloadContentRaw) : '';
|
||||
const rawIdentifier = section?.id || section?.section_id || section?.sectionId || section?.sectionID || `section_${index + 1}`;
|
||||
const identifier = String(rawIdentifier).trim();
|
||||
|
||||
return {
|
||||
id: identifier,
|
||||
heading: section.heading,
|
||||
content: payloadContent,
|
||||
};
|
||||
});
|
||||
|
||||
const response = await blogWriterApi.applySeoRecommendations({
|
||||
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
|
||||
sections: sectionPayload,
|
||||
outline,
|
||||
research: (research as any) || {},
|
||||
recommendations,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to apply recommendations.');
|
||||
}
|
||||
|
||||
if (!response.sections || !Array.isArray(response.sections)) {
|
||||
throw new Error('Recommendation response did not include updated sections.');
|
||||
}
|
||||
|
||||
const { byId: responseById, byHeading: responseByHeading } = buildResponseContentMaps(response.sections);
|
||||
|
||||
const normalizedSections: Record<string, string> = {};
|
||||
const sectionKeysForCache: string[] = [];
|
||||
|
||||
outline.forEach((section, index) => {
|
||||
const { content: resolvedContent, matchedKey } = resolveContentForOutlineSection(
|
||||
section,
|
||||
index,
|
||||
response.sections,
|
||||
responseById,
|
||||
responseByHeading,
|
||||
existingContentMap
|
||||
);
|
||||
|
||||
const finalContent = (resolvedContent ?? '').trim();
|
||||
const contentToUse = finalContent || '';
|
||||
const primaryKey = getPrimaryKeyForOutlineSection(section, index);
|
||||
|
||||
normalizedSections[primaryKey] = contentToUse;
|
||||
sectionKeysForCache.push(primaryKey);
|
||||
});
|
||||
|
||||
const uniqueSectionKeys = Array.from(new Set(sectionKeysForCache));
|
||||
|
||||
if (uniqueSectionKeys.length === 0) {
|
||||
throw new Error('No valid sections received from SEO recommendations application.');
|
||||
}
|
||||
|
||||
const sectionsWithContent = Object.values(normalizedSections).filter(c => c && c.trim().length > 0);
|
||||
if (sectionsWithContent.length === 0) {
|
||||
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
|
||||
}
|
||||
|
||||
debug.log('[BlogWriter] Applied SEO recommendations: sections normalized', {
|
||||
sectionCount: uniqueSectionKeys.length,
|
||||
sectionsWithContent: sectionsWithContent.length,
|
||||
sectionKeys: uniqueSectionKeys,
|
||||
totalContentLength: Object.values(normalizedSections).reduce((sum, c) => sum + (c?.length || 0), 0)
|
||||
});
|
||||
|
||||
setSections(normalizedSections);
|
||||
|
||||
try {
|
||||
blogWriterCache.cacheContent(normalizedSections, uniqueSectionKeys);
|
||||
} catch (cacheError) {
|
||||
debug.log('[BlogWriter] Failed to cache SEO-applied content', cacheError);
|
||||
}
|
||||
|
||||
// Force a delay to ensure React processes the state update before proceeding
|
||||
// This gives React time to re-render with new sections before phase navigation checks
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
setContinuityRefresh(Date.now());
|
||||
setFlowAnalysisCompleted(false);
|
||||
setFlowAnalysisResults(null);
|
||||
|
||||
if (response.title && response.title !== selectedTitle) {
|
||||
setSelectedTitle(response.title);
|
||||
}
|
||||
|
||||
if (response.applied) {
|
||||
setSeoAnalysis((prev: any) => prev ? { ...prev, applied_recommendations: response.applied } : prev);
|
||||
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
|
||||
}
|
||||
|
||||
// Mark recommendations as applied (this will trigger phase navigation check)
|
||||
// But we'll stay in SEO phase to show updated content
|
||||
setSeoRecommendationsApplied(true);
|
||||
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content
|
||||
// Force navigation to SEO phase if we're not already there (safeguard)
|
||||
if (currentPhase !== 'seo') {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
|
||||
} else {
|
||||
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
|
||||
}
|
||||
}, [outline, research, sections, selectedTitle, setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSelectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
|
||||
|
||||
// Handle SEO analysis completion
|
||||
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||
setSeoAnalysis(analysis);
|
||||
debug.log('[BlogWriter] SEO analysis completed', { hasAnalysis: !!analysis });
|
||||
}, [setSeoAnalysis]);
|
||||
|
||||
// Handle SEO modal close - mark SEO phase as done if not already marked
|
||||
const handleSEOModalClose = useCallback(() => {
|
||||
// Mark SEO phase as done when modal closes (even without applying recommendations)
|
||||
if (!seoAnalysis) {
|
||||
// Set a minimal valid seoAnalysis object to mark phase as complete
|
||||
setSeoAnalysis({
|
||||
success: true,
|
||||
overall_score: 0,
|
||||
category_scores: {},
|
||||
analysis_summary: {
|
||||
overall_grade: 'N/A',
|
||||
status: 'Skipped',
|
||||
strongest_category: 'N/A',
|
||||
weakest_category: 'N/A',
|
||||
key_strengths: [],
|
||||
key_weaknesses: [],
|
||||
ai_summary: 'SEO analysis was skipped by user'
|
||||
},
|
||||
actionable_recommendations: [],
|
||||
generated_at: new Date().toISOString()
|
||||
});
|
||||
debug.log('[BlogWriter] SEO phase marked as done (modal closed without analysis)');
|
||||
}
|
||||
setIsSEOAnalysisModalOpen(false);
|
||||
debug.log('[BlogWriter] SEO modal closed');
|
||||
}, [seoAnalysis, setSeoAnalysis]);
|
||||
|
||||
// Mark SEO phase as completed when recommendations are applied
|
||||
useEffect(() => {
|
||||
if (seoRecommendationsApplied && seoAnalysis) {
|
||||
// SEO phase is considered complete when recommendations are applied
|
||||
// But stay in SEO phase to show updated content
|
||||
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content (override auto-progression)
|
||||
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
|
||||
}
|
||||
}
|
||||
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
|
||||
|
||||
const confirmBlogContent = useCallback(() => {
|
||||
debug.log('[BlogWriter] Blog content confirmed by user');
|
||||
setContentConfirmed(true);
|
||||
setSeoRecommendationsApplied(false);
|
||||
navigateToPhase('seo');
|
||||
setTimeout(() => {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
|
||||
}, 0);
|
||||
return "✅ Blog content has been confirmed! Running SEO analysis now.";
|
||||
}, [setContentConfirmed, navigateToPhase]);
|
||||
|
||||
return {
|
||||
isSEOAnalysisModalOpen,
|
||||
setIsSEOAnalysisModalOpen,
|
||||
isSEOMetadataModalOpen,
|
||||
setIsSEOMetadataModalOpen,
|
||||
seoRecommendationsApplied,
|
||||
setSeoRecommendationsApplied,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
handleApplySeoRecommendations,
|
||||
handleSEOAnalysisComplete,
|
||||
handleSEOModalClose,
|
||||
confirmBlogContent,
|
||||
};
|
||||
};
|
||||
|
||||
export type SEOManagerReturn = ReturnType<typeof useSEOManager>;
|
||||
|
||||
178
frontend/src/components/BlogWriter/ContinuityBadge.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { debug } from '../../utils/debug';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
refreshToken?: number;
|
||||
disabled?: boolean;
|
||||
flowAnalysisResults?: any;
|
||||
}
|
||||
|
||||
export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken, disabled = false, flowAnalysisResults }) => {
|
||||
const [metrics, setMetrics] = useState<Record<string, number> | null>(null);
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
// If we have flow analysis results, use them instead of API call
|
||||
if (flowAnalysisResults && flowAnalysisResults.sections) {
|
||||
const sectionAnalysis = flowAnalysisResults.sections.find((s: any) => s.section_id === sectionId);
|
||||
if (sectionAnalysis) {
|
||||
if (mounted) {
|
||||
setMetrics({
|
||||
flow: sectionAnalysis.flow_score,
|
||||
consistency: sectionAnalysis.consistency_score,
|
||||
progression: sectionAnalysis.progression_score
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to API call if no flow analysis results
|
||||
debug.log('[ContinuityBadge] fetching', { sectionId });
|
||||
blogWriterApi.getContinuity(sectionId)
|
||||
.then(res => {
|
||||
if (mounted) setMetrics(res.continuity_metrics || null);
|
||||
})
|
||||
.catch((error) => {
|
||||
debug.error('[ContinuityBadge] fetch error', error);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [sectionId, refreshToken, flowAnalysisResults]);
|
||||
|
||||
// Show badge even if metrics are null (for debugging)
|
||||
const flow = metrics ? Math.round(((metrics.flow || 0) * 100)) : 0;
|
||||
const consistency = metrics ? Math.round(((metrics.consistency || 0) * 100)) : 0;
|
||||
const progression = metrics ? Math.round(((metrics.progression || 0) * 100)) : 0;
|
||||
|
||||
// Enable badge if we have flow analysis results or metrics
|
||||
const isEnabled = !disabled || (flowAnalysisResults && flowAnalysisResults.sections) || metrics;
|
||||
|
||||
// Enhanced color coding with actionable feedback
|
||||
const getFlowColor = (score: number) => {
|
||||
if (score >= 80) return '#2e7d32'; // Green - Excellent
|
||||
if (score >= 60) return '#f9a825'; // Yellow - Good
|
||||
return '#c62828'; // Red - Needs improvement
|
||||
};
|
||||
|
||||
const getFlowSuggestion = (score: number) => {
|
||||
if (score >= 80) return "🎉 Excellent narrative flow!";
|
||||
if (score >= 60) return "💡 Good flow - try connecting ideas more smoothly";
|
||||
return "🔧 Consider adding transitions between paragraphs";
|
||||
};
|
||||
|
||||
const getConsistencySuggestion = (score: number) => {
|
||||
if (score >= 80) return "✨ Consistent tone and style";
|
||||
if (score >= 60) return "📝 Good consistency - maintain your voice";
|
||||
return "🎯 Work on maintaining consistent tone throughout";
|
||||
};
|
||||
|
||||
const getProgressionSuggestion = (score: number) => {
|
||||
if (score >= 80) return "🚀 Great logical progression!";
|
||||
if (score >= 60) return "📈 Good progression - build on previous points";
|
||||
return "🔗 Strengthen connections between ideas";
|
||||
};
|
||||
|
||||
const color = getFlowColor(flow);
|
||||
|
||||
return (
|
||||
<span
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{ position: 'relative', display: 'inline-block' }}
|
||||
>
|
||||
<span
|
||||
title={!isEnabled ? 'Flow analysis disabled - use Copilot to enable' : (metrics ? `Flow ${flow}%` : 'Flow metrics not available')}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 12,
|
||||
color: !isEnabled ? '#999' : (metrics ? color : '#666'),
|
||||
border: `1px solid ${!isEnabled ? '#ddd' : (metrics ? color : '#ccc')}`,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 10,
|
||||
background: !isEnabled ? '#f5f5f5' : 'transparent',
|
||||
cursor: !isEnabled ? 'not-allowed' : 'default',
|
||||
opacity: !isEnabled ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
{!isEnabled ? 'Flow --' : (metrics ? `Flow ${flow}%` : 'Flow --')}
|
||||
</span>
|
||||
|
||||
{hover && isEnabled && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '150%',
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
background: '#fff',
|
||||
color: '#333',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 12,
|
||||
padding: '12px 16px',
|
||||
minWidth: 280,
|
||||
maxWidth: 320,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
backdropFilter: 'blur(8px)'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 12, color: '#1a1a1a' }}>
|
||||
📊 Content Quality Analysis
|
||||
</div>
|
||||
|
||||
{/* Flow Analysis */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Flow</span>
|
||||
<span style={{ color: getFlowColor(flow), fontWeight: 600 }}>{flow}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getFlowSuggestion(flow)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consistency Analysis */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Consistency</span>
|
||||
<span style={{ color: getFlowColor(consistency), fontWeight: 600 }}>{consistency}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getConsistencySuggestion(consistency)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progression Analysis */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600 }}>Progression</span>
|
||||
<span style={{ color: getFlowColor(progression), fontWeight: 600 }}>{progression}%</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#666', lineHeight: '1.4' }}>
|
||||
{getProgressionSuggestion(progression)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall Quality Indicator */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
paddingTop: 8,
|
||||
marginTop: 8,
|
||||
fontSize: 11,
|
||||
color: '#888',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
💡 Hover over other sections to compare quality metrics
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContinuityBadge;
|
||||
|
||||
|
||||
142
frontend/src/components/BlogWriter/CustomOutlineForm.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface CustomOutlineFormProps {
|
||||
onOutlineCreated?: (outline: any) => void;
|
||||
}
|
||||
|
||||
export const CustomOutlineForm: React.FC<CustomOutlineFormProps> = ({ onOutlineCreated }) => {
|
||||
const [customInstructions, setCustomInstructions] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'getCustomOutlineInstructions',
|
||||
description: 'Get custom instructions from user for outline generation',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Custom outline instructions received! Creating your personalized outline...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
🎨 Create Custom Outline
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{args.prompt || 'Tell me your specific requirements for the blog outline. What should it focus on? What structure do you prefer?'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Custom Instructions *
|
||||
</label>
|
||||
<textarea
|
||||
value={customInstructions}
|
||||
onChange={(e) => setCustomInstructions(e.target.value)}
|
||||
placeholder="e.g., Focus on beginner-friendly explanations, include case studies, emphasize practical applications, create a step-by-step guide format..."
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
padding: '12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'vertical',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<h5 style={{ margin: '0 0 8px 0', color: '#1976d2', fontSize: '14px' }}>💡 Examples:</h5>
|
||||
<ul style={{ margin: '0', paddingLeft: '20px', fontSize: '13px', color: '#333' }}>
|
||||
<li>"Focus on beginner-friendly explanations with practical examples"</li>
|
||||
<li>"Include case studies and real-world applications"</li>
|
||||
<li>"Create a step-by-step tutorial format"</li>
|
||||
<li>"Emphasize the business benefits and ROI"</li>
|
||||
<li>"Make it more technical and detailed for developers"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (customInstructions.trim()) {
|
||||
respond?.(customInstructions.trim());
|
||||
} else {
|
||||
window.alert('Please provide your custom instructions for the outline.');
|
||||
}
|
||||
}}
|
||||
disabled={!customInstructions.trim() || isSubmitting}
|
||||
style={{
|
||||
backgroundColor: customInstructions.trim() ? '#1976d2' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: customInstructions.trim() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? '⏳ Creating...' : '🚀 Create Custom Outline'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => respond?.('CANCEL')}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit action, no UI
|
||||
};
|
||||
51
frontend/src/components/BlogWriter/DiffPreview.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
original: string;
|
||||
updated: string;
|
||||
onApply: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
function highlightDiff(a: string, b: string) {
|
||||
// Simple common prefix/suffix highlighting
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
||||
let j = 0;
|
||||
while (j < a.length - i && j < b.length - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j++;
|
||||
const aMid = a.substring(i, a.length - j);
|
||||
const bMid = b.substring(i, b.length - j);
|
||||
const aHtml = `${escapeHtml(a.substring(0, i))}<span style="background:#ffe5e5;text-decoration:line-through;">${escapeHtml(aMid)}</span>${escapeHtml(a.substring(a.length - j))}`;
|
||||
const bHtml = `${escapeHtml(b.substring(0, i))}<span style="background:#e6ffed;">${escapeHtml(bMid)}</span>${escapeHtml(b.substring(b.length - j))}`;
|
||||
return { aHtml, bHtml };
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const DiffPreview: React.FC<Props> = ({ original, updated, onApply, onDiscard }) => {
|
||||
const { aHtml, bHtml } = highlightDiff(original, updated);
|
||||
return (
|
||||
<div style={{ border: '1px solid #ddd', padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Preview Changes</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, background: '#fafafa', padding: 8 }} dangerouslySetInnerHTML={{ __html: aHtml }} />
|
||||
<div style={{ flex: 1, background: '#f5fff5', padding: 8 }} dangerouslySetInnerHTML={{ __html: bHtml }} />
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
|
||||
<button onClick={onApply}>Apply</button>
|
||||
<button onClick={onDiscard}>Discard</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffPreview;
|
||||
|
||||
|
||||
200
frontend/src/components/BlogWriter/EnhancedOutlineActions.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface EnhancedOutlineActionsProps {
|
||||
outline: BlogOutlineSection[];
|
||||
onOutlineUpdated: (outline: BlogOutlineSection[]) => void;
|
||||
}
|
||||
|
||||
export const EnhancedOutlineActions: React.FC<EnhancedOutlineActionsProps> = ({
|
||||
outline,
|
||||
onOutlineUpdated
|
||||
}) => {
|
||||
// Enhanced Outline Actions
|
||||
useCopilotActionTyped({
|
||||
name: 'enhanceSection',
|
||||
description: 'Enhance a specific outline section with AI improvements',
|
||||
parameters: [
|
||||
{ name: 'sectionId', type: 'string', description: 'ID of the section to enhance', required: true },
|
||||
{ name: 'focus', type: 'string', description: 'Enhancement focus (SEO, engagement, depth, etc.)', required: false }
|
||||
],
|
||||
handler: async ({ sectionId, focus = 'general improvement' }: { sectionId: string; focus?: string }) => {
|
||||
const section = outline.find(s => s.id === sectionId);
|
||||
if (!section) return { success: false, message: 'Section not found' };
|
||||
|
||||
try {
|
||||
const enhancedSection = await blogWriterApi.enhanceSection(section, focus);
|
||||
onOutlineUpdated(outline.map(s => s.id === sectionId ? enhancedSection : s));
|
||||
return {
|
||||
success: true,
|
||||
message: `Enhanced section "${section.heading}" with focus on ${focus}`,
|
||||
enhanced_section: enhancedSection
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: `Enhancement failed: ${error}` };
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #9c27b0',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#9c27b0' }}>✨ Enhancing Section</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing section content and structure...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Generating enhanced subheadings and key points...</p>
|
||||
<p style={{ margin: '0' }}>• Optimizing for better engagement and SEO...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'optimizeOutline',
|
||||
description: 'Optimize entire outline for better flow, SEO, and engagement',
|
||||
parameters: [
|
||||
{ name: 'focus', type: 'string', description: 'Optimization focus (flow, SEO, engagement, etc.)', required: false }
|
||||
],
|
||||
handler: async ({ focus = 'general optimization' }: { focus?: string }) => {
|
||||
if (outline.length === 0) return { success: false, message: 'No outline to optimize' };
|
||||
|
||||
try {
|
||||
const optimizedOutline = await blogWriterApi.optimizeOutline({ outline }, focus);
|
||||
onOutlineUpdated(optimizedOutline.outline);
|
||||
return {
|
||||
success: true,
|
||||
message: `Optimized outline with focus on ${focus}`,
|
||||
optimized_outline: optimizedOutline.outline
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: `Optimization failed: ${error}` };
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #ff9800',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#ff9800' }}>🎯 Optimizing Outline</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing outline structure and flow...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Optimizing headings for SEO and engagement...</p>
|
||||
<p style={{ margin: '0' }}>• Improving narrative progression and reader experience...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'rebalanceOutline',
|
||||
description: 'Rebalance word count distribution across outline sections',
|
||||
parameters: [
|
||||
{ name: 'targetWords', type: 'number', description: 'Target total word count', required: false }
|
||||
],
|
||||
handler: async ({ targetWords = 1500 }: { targetWords?: number }) => {
|
||||
if (outline.length === 0) return { success: false, message: 'No outline to rebalance' };
|
||||
|
||||
try {
|
||||
const rebalancedOutline = await blogWriterApi.rebalanceOutline({ outline }, targetWords);
|
||||
onOutlineUpdated(rebalancedOutline.outline);
|
||||
return {
|
||||
success: true,
|
||||
message: `Rebalanced outline for ${targetWords} words`,
|
||||
rebalanced_outline: rebalancedOutline.outline
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, message: `Rebalancing failed: ${error}` };
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #4caf50',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#4caf50' }}>⚖️ Rebalancing Word Counts</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Calculating optimal word distribution...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Adjusting section word counts...</p>
|
||||
<p style={{ margin: '0' }}>• Ensuring balanced content structure...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the CopilotKit actions, no UI
|
||||
};
|
||||
841
frontend/src/components/BlogWriter/EnhancedOutlineEditor.tsx
Normal file
@@ -0,0 +1,841 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage, blogWriterApi } from '../../services/blogWriterApi';
|
||||
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
|
||||
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
|
||||
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
|
||||
|
||||
interface Props {
|
||||
outline: BlogOutlineSection[];
|
||||
onRefine: (operation: string, sectionId?: string, payload?: any) => void;
|
||||
research?: any; // Research data for context
|
||||
sourceMappingStats?: SourceMappingStats | null;
|
||||
groundingInsights?: GroundingInsights | null;
|
||||
optimizationResults?: OptimizationResults | null;
|
||||
researchCoverage?: ResearchCoverage | null;
|
||||
sectionImages?: Record<string, string>;
|
||||
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
|
||||
}
|
||||
|
||||
const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
outline,
|
||||
onRefine,
|
||||
research,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
sectionImages = {},
|
||||
setSectionImages
|
||||
}) => {
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [hoveredSection, setHoveredSection] = useState<string | null>(null);
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
const [imageModalState, setImageModalState] = useState<{ open: boolean; sectionId?: string }>(() => ({ open: false }));
|
||||
const [newSectionData, setNewSectionData] = useState({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
||||
const [refineFeedback, setRefineFeedback] = useState('');
|
||||
const [isRefining, setIsRefining] = useState(false);
|
||||
|
||||
const toggleExpanded = (sectionId: string) => {
|
||||
const newExpanded = new Set(expandedSections);
|
||||
if (newExpanded.has(sectionId)) {
|
||||
newExpanded.delete(sectionId);
|
||||
} else {
|
||||
newExpanded.add(sectionId);
|
||||
}
|
||||
setExpandedSections(newExpanded);
|
||||
};
|
||||
|
||||
const handleRename = (sectionId: string, newHeading: string) => {
|
||||
if (newHeading.trim()) {
|
||||
onRefine('rename', sectionId, { heading: newHeading.trim() });
|
||||
}
|
||||
setEditingSection(null);
|
||||
};
|
||||
|
||||
const handleMove = (sectionId: string, direction: 'up' | 'down') => {
|
||||
onRefine('move', sectionId, { direction });
|
||||
};
|
||||
|
||||
const handleAddSection = () => {
|
||||
if (newSectionData.heading.trim()) {
|
||||
const subheadings = newSectionData.subheadings
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
const keyPoints = newSectionData.key_points
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
onRefine('add', undefined, {
|
||||
heading: newSectionData.heading.trim(),
|
||||
subheadings,
|
||||
key_points: keyPoints,
|
||||
target_words: newSectionData.target_words
|
||||
});
|
||||
|
||||
setNewSectionData({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
key_points: '',
|
||||
target_words: 300
|
||||
});
|
||||
setShowAddSection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefineOutline = async () => {
|
||||
if (!refineFeedback.trim()) {
|
||||
alert('Please provide feedback on how you would like to refine the outline.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefining(true);
|
||||
try {
|
||||
// Use the parent's onRefine callback which handles the API call and state update
|
||||
// The callback expects: operation, sectionId, payload
|
||||
await onRefine('refine', undefined, { feedback: refineFeedback.trim() });
|
||||
|
||||
setRefineFeedback('');
|
||||
setShowRefineModal(false);
|
||||
|
||||
// Show success message
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
`;
|
||||
toast.textContent = '✅ Outline refined successfully!';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => document.body.removeChild(toast), 3000);
|
||||
} catch (error) {
|
||||
console.error('Failed to refine outline:', error);
|
||||
alert('Failed to refine outline. Please try again.');
|
||||
} finally {
|
||||
setIsRefining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTotalWords = () => {
|
||||
return outline.reduce((total, section) => total + (section.target_words || 0), 0);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{imageModalState.open && (
|
||||
<ImageGeneratorModal
|
||||
isOpen={imageModalState.open}
|
||||
onClose={() => setImageModalState({ open: false })}
|
||||
defaultPrompt={(() => {
|
||||
const sec = outline.find(s => s.id === imageModalState.sectionId);
|
||||
return sec?.heading || '';
|
||||
})()}
|
||||
context={(() => {
|
||||
const sec = outline.find(s => s.id === imageModalState.sectionId);
|
||||
return {
|
||||
title: sec?.heading,
|
||||
section: sec,
|
||||
outline,
|
||||
research,
|
||||
sectionId: imageModalState.sectionId
|
||||
};
|
||||
})()}
|
||||
onImageGenerated={(imageBase64, sectionId) => {
|
||||
if (sectionId && setSectionImages) {
|
||||
setSectionImages((prev: Record<string, string>) => ({ ...prev, [sectionId]: imageBase64 }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: '#333', fontSize: '20px' }}>
|
||||
📋 Blog Outline
|
||||
</h2>
|
||||
<p style={{ margin: '4px 0 0 0', color: '#666', fontSize: '14px' }}>
|
||||
{outline.length} sections • {getTotalWords()} words total
|
||||
</p>
|
||||
</div>
|
||||
{/* Intelligence Chips inline with title */}
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<OutlineIntelligenceChips
|
||||
sections={outline}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setShowRefineModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#7b1fa2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
title="Refine the outline structure based on your feedback"
|
||||
>
|
||||
🔧 Refine Outline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddSection(!showAddSection)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
➕ Add Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Add Section Form */}
|
||||
{showAddSection && (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Add New Section</h3>
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Section Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSectionData.heading}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, heading: e.target.value})}
|
||||
placeholder="Enter section title..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Subheadings (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
value={newSectionData.subheadings}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, subheadings: e.target.value})}
|
||||
placeholder="Subheading 1 Subheading 2 Subheading 3"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Key Points (one per line)
|
||||
</label>
|
||||
<textarea
|
||||
value={newSectionData.key_points}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, key_points: e.target.value})}
|
||||
placeholder="Key point 1 Key point 2 Key point 3"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Target Words
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSectionData.target_words}
|
||||
onChange={(e) => setNewSectionData({...newSectionData, target_words: parseInt(e.target.value) || 300})}
|
||||
min="100"
|
||||
max="2000"
|
||||
style={{
|
||||
width: '120px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={handleAddSection}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Add Section
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddSection(false)}
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outline Sections */}
|
||||
<div style={{ padding: '0' }}>
|
||||
{outline.map((section, index) => (
|
||||
<div key={section.id} style={{
|
||||
borderBottom: index < outline.length - 1 ? '1px solid #f0f0f0' : 'none',
|
||||
transition: 'all 0.2s ease'
|
||||
}}>
|
||||
{/* Section Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: expandedSections.has(section.id) || hoveredSection === section.id ? '#f8f9fa' : 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredSection(section.id)}
|
||||
onMouseLeave={() => setHoveredSection(null)}
|
||||
onClick={() => toggleExpanded(section.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{editingSection === section.id ? (
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={section.heading}
|
||||
onBlur={(e) => handleRename(section.id, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename(section.id, e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
border: '1px solid #1976d2',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
backgroundColor: 'white'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
flex: 1
|
||||
}}>
|
||||
{section.heading}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{section.target_words || 300} words
|
||||
</span>
|
||||
|
||||
{section.references && section.references.length > 0 && (
|
||||
<span style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{section.references.length} sources
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSection(section.id);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageModalState({ open: true, sectionId: section.id });
|
||||
}}
|
||||
title="Generate Image"
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
🖼️ Generate Image
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove(section.id, 'up');
|
||||
}}
|
||||
disabled={index === 0}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: index === 0 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
color: index === 0 ? '#ccc' : '#666',
|
||||
opacity: index === 0 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
⬆️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMove(section.id, 'down');
|
||||
}}
|
||||
disabled={index === outline.length - 1}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: index === outline.length - 1 ? 'not-allowed' : 'pointer',
|
||||
fontSize: '12px',
|
||||
color: index === outline.length - 1 ? '#ccc' : '#666',
|
||||
opacity: index === outline.length - 1 ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
⬇️
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm(`Are you sure you want to remove "${section.heading}"?`)) {
|
||||
onRefine('remove', section.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #f44336',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#f44336'
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
|
||||
<div style={{
|
||||
transform: expandedSections.has(section.id) ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}>
|
||||
▼
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Section Content */}
|
||||
{(expandedSections.has(section.id) || hoveredSection === section.id) && (
|
||||
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
|
||||
{/* Subheadings */}
|
||||
{section.subheadings && section.subheadings.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
📝 Subheadings
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{section.subheadings.map((subheading, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
{subheading}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Points */}
|
||||
{section.key_points && section.key_points.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🎯 Key Points
|
||||
</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{section.key_points.map((point, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||
{point}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keywords */}
|
||||
{section.keywords && section.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🎯 SEO Keywords
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{section.keywords.map((keyword, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* References */}
|
||||
{section.references && section.references.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
📚 Sources ({section.references.length})
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{section.references.map((ref, i) => (
|
||||
<div key={i} style={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
maxWidth: '200px'
|
||||
}}>
|
||||
<div style={{ fontWeight: '500', marginBottom: '2px' }}>
|
||||
{ref.title}
|
||||
</div>
|
||||
<div style={{ color: '#999' }}>
|
||||
Credibility: {Math.round((ref.credibility_score || 0.8) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated Image Display */}
|
||||
{sectionImages[section.id] && (
|
||||
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🖼️ Generated Image
|
||||
</h4>
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
maxWidth: '600px',
|
||||
backgroundColor: 'white'
|
||||
}}>
|
||||
<img
|
||||
src={`data:image/png;base64,${sectionImages[section.id]}`}
|
||||
alt={`Generated image for ${section.heading}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageModalState({ open: true, sectionId: section.id });
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Generate Image for this section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
💡 Tip: Click on any section to expand and see details. Use the controls to reorder, edit, or remove sections.
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
Total: {getTotalWords()} words
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refine Outline Modal */}
|
||||
{showRefineModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '600px',
|
||||
width: '90%',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: '0 0 8px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600' }}>
|
||||
🔧 Refine Outline
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Provide feedback on how you'd like to improve the outline structure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#333'
|
||||
}}>
|
||||
Your Feedback
|
||||
</label>
|
||||
<textarea
|
||||
value={refineFeedback}
|
||||
onChange={(e) => setRefineFeedback(e.target.value)}
|
||||
placeholder="E.g., Add a section about best practices, merge sections 2 and 3, expand the introduction..."
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '120px',
|
||||
padding: '12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRefineModal(false);
|
||||
setRefineFeedback('');
|
||||
}}
|
||||
disabled={isRefining}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: isRefining ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefineOutline}
|
||||
disabled={isRefining || !refineFeedback.trim()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: isRefining || !refineFeedback.trim() ? '#9ca3af' : '#7b1fa2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: isRefining || !refineFeedback.trim() ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{isRefining ? (
|
||||
<>
|
||||
<span>⏳</span>
|
||||
<span>Refining...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>🔧</span>
|
||||
<span>Refine Outline</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedOutlineEditor;
|
||||
469
frontend/src/components/BlogWriter/EnhancedOutlineInsights.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection } from '../../services/blogWriterApi';
|
||||
|
||||
interface GroundingInsights {
|
||||
confidence_analysis?: {
|
||||
average_confidence: number;
|
||||
high_confidence_sources_count: number;
|
||||
confidence_distribution: { high: number; medium: number; low: number };
|
||||
};
|
||||
authority_analysis?: {
|
||||
average_authority_score: number;
|
||||
high_authority_sources: Array<{ title: string; url: string; score: number }>;
|
||||
};
|
||||
content_relationships?: {
|
||||
related_concepts: string[];
|
||||
content_gaps: string[];
|
||||
concept_coverage_score: number;
|
||||
};
|
||||
search_intent_insights?: {
|
||||
primary_intent: string;
|
||||
user_questions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface SourceMappingStats {
|
||||
total_sources_mapped: number;
|
||||
coverage_percentage: number;
|
||||
average_relevance_score: number;
|
||||
high_confidence_mappings: number;
|
||||
}
|
||||
|
||||
interface OptimizationResults {
|
||||
overall_quality_score: number;
|
||||
improvements_made: string[];
|
||||
optimization_focus: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sections: BlogOutlineSection[];
|
||||
groundingInsights?: GroundingInsights;
|
||||
sourceMappingStats?: SourceMappingStats;
|
||||
optimizationResults?: OptimizationResults;
|
||||
researchCoverage?: {
|
||||
sources_utilized: number;
|
||||
content_gaps_identified: number;
|
||||
competitive_advantages: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const EnhancedOutlineInsights: React.FC<Props> = ({
|
||||
sections,
|
||||
groundingInsights,
|
||||
sourceMappingStats,
|
||||
optimizationResults,
|
||||
researchCoverage
|
||||
}) => {
|
||||
const [expandedInsights, setExpandedInsights] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleInsight = (insightType: string) => {
|
||||
const newExpanded = new Set(expandedInsights);
|
||||
if (newExpanded.has(insightType)) {
|
||||
newExpanded.delete(insightType);
|
||||
} else {
|
||||
newExpanded.add(insightType);
|
||||
}
|
||||
setExpandedInsights(newExpanded);
|
||||
};
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
if (score >= 0.8) return '#4caf50'; // Green
|
||||
if (score >= 0.6) return '#ff9800'; // Orange
|
||||
return '#f44336'; // Red
|
||||
};
|
||||
|
||||
const getQualityGrade = (score: number) => {
|
||||
if (score >= 9) return { grade: 'A+', color: '#4caf50' };
|
||||
if (score >= 8) return { grade: 'A', color: '#4caf50' };
|
||||
if (score >= 7) return { grade: 'B+', color: '#8bc34a' };
|
||||
if (score >= 6) return { grade: 'B', color: '#ff9800' };
|
||||
if (score >= 5) return { grade: 'C', color: '#ff9800' };
|
||||
return { grade: 'D', color: '#f44336' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
margin: '20px 0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '16px 20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600' }}>
|
||||
🧠 Outline Intelligence & Insights
|
||||
</h3>
|
||||
<span style={{ fontSize: '12px', opacity: 0.9 }}>
|
||||
{sections.length} sections analyzed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px' }}>
|
||||
{/* Research Coverage */}
|
||||
{researchCoverage && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('research') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('research')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>📊</span>
|
||||
<span style={{ fontWeight: '600' }}>Research Data Utilization</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('research') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('research') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#1976d2' }}>
|
||||
{researchCoverage.sources_utilized}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Sources Utilized</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#ff9800' }}>
|
||||
{researchCoverage.content_gaps_identified}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Content Gaps Identified</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '24px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{researchCoverage.competitive_advantages.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Competitive Advantages</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{researchCoverage.competitive_advantages.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Key Advantages:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{researchCoverage.competitive_advantages.map((advantage, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{advantage}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Mapping Intelligence */}
|
||||
{sourceMappingStats && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('mapping') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('mapping')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🔗</span>
|
||||
<span style={{ fontWeight: '600' }}>Source Mapping Intelligence</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('mapping') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('mapping') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#1976d2' }}>
|
||||
{sourceMappingStats.total_sources_mapped}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Sources Mapped</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100) }}>
|
||||
{sourceMappingStats.coverage_percentage}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Coverage</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.average_relevance_score) }}>
|
||||
{(sourceMappingStats.average_relevance_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Relevance</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{sourceMappingStats.high_confidence_mappings}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>High Confidence</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grounding Insights */}
|
||||
{groundingInsights && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('grounding') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('grounding')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🧠</span>
|
||||
<span style={{ fontWeight: '600' }}>Grounding Metadata Insights</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('grounding') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('grounding') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
{/* Confidence Analysis */}
|
||||
{groundingInsights.confidence_analysis && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Confidence Analysis</h5>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.confidence_analysis.average_confidence) }}>
|
||||
{(groundingInsights.confidence_analysis.average_confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Confidence</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: '#4caf50' }}>
|
||||
{groundingInsights.confidence_analysis.high_confidence_sources_count}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>High Confidence Sources</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authority Analysis */}
|
||||
{groundingInsights.authority_analysis && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Authority Analysis</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.authority_analysis.average_authority_score) }}>
|
||||
{(groundingInsights.authority_analysis.average_authority_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Avg Authority</div>
|
||||
</div>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Top Authority Sources:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.slice(0, 3).map((source, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{source.title.substring(0, 30)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Relationships */}
|
||||
{groundingInsights.content_relationships && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Content Relationships</h5>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: '700', color: getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score) }}>
|
||||
{(groundingInsights.content_relationships.concept_coverage_score * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Concept Coverage</div>
|
||||
</div>
|
||||
{groundingInsights.content_relationships.related_concepts.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>Related Concepts:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.content_relationships.related_concepts.slice(0, 5).map((concept, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#f57c00',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Intent */}
|
||||
{groundingInsights.search_intent_insights && (
|
||||
<div>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Search Intent Analysis</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '700', color: '#1976d2', textTransform: 'capitalize' }}>
|
||||
{groundingInsights.search_intent_insights.primary_intent}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Primary Intent</div>
|
||||
</div>
|
||||
{groundingInsights.search_intent_insights.user_questions.length > 0 && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>User Questions:</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
|
||||
{groundingInsights.search_intent_insights.user_questions.slice(0, 3).map((question, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '10px'
|
||||
}}>
|
||||
{question.substring(0, 40)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optimization Results */}
|
||||
{optimizationResults && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
padding: '12px',
|
||||
backgroundColor: expandedInsights.has('optimization') ? '#e3f2fd' : 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
onClick={() => toggleInsight('optimization')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '18px' }}>🎯</span>
|
||||
<span style={{ fontWeight: '600' }}>Optimization Results</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '700',
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
}}>
|
||||
{getQualityGrade(optimizationResults.overall_quality_score).grade}
|
||||
</span>
|
||||
<span style={{ fontSize: '14px', color: '#666' }}>
|
||||
{expandedInsights.has('optimization') ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedInsights.has('optimization') && (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fafafa', borderTop: '1px solid #e0e0e0' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Quality Assessment</h5>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
}}>
|
||||
{optimizationResults.overall_quality_score}/10
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Overall Quality</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: '700', color: '#1976d2', textTransform: 'capitalize' }}>
|
||||
{optimizationResults.optimization_focus}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>Focus Area</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{optimizationResults.improvements_made.length > 0 && (
|
||||
<div>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Improvements Made:</h5>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{optimizationResults.improvements_made.map((improvement, i) => (
|
||||
<li key={i} style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
||||
{improvement}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedOutlineInsights;
|
||||
707
frontend/src/components/BlogWriter/EnhancedTitleSelector.tsx
Normal file
@@ -0,0 +1,707 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../services/blogWriterApi';
|
||||
|
||||
interface EnhancedTitleSelectorProps {
|
||||
titleOptions: string[];
|
||||
selectedTitle?: string;
|
||||
onTitleSelect: (title: string) => void;
|
||||
onCustomTitle?: (title: string) => void;
|
||||
sections: BlogOutlineSection[];
|
||||
researchTitles?: string[];
|
||||
aiGeneratedTitles?: string[];
|
||||
research?: BlogResearchResponse;
|
||||
onTitlesGenerated?: (titles: string[]) => void;
|
||||
}
|
||||
|
||||
const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
onTitleSelect,
|
||||
onCustomTitle,
|
||||
sections,
|
||||
researchTitles = [],
|
||||
aiGeneratedTitles = [],
|
||||
research,
|
||||
onTitlesGenerated
|
||||
}) => {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [generatedTitles, setGeneratedTitles] = useState<string[]>([]);
|
||||
const [generationProgress, setGenerationProgress] = useState<string>('');
|
||||
|
||||
const handleTitleSelect = (title: string) => {
|
||||
onTitleSelect(title);
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const handleCustomTitleSubmit = () => {
|
||||
if (customTitle.trim() && onCustomTitle) {
|
||||
onCustomTitle(customTitle.trim());
|
||||
setCustomTitle('');
|
||||
setShowModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateSEOTitles = async () => {
|
||||
if (!research || !sections.length || isGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setGenerationProgress('Analyzing research data and outline structure...');
|
||||
|
||||
try {
|
||||
const keywordAnalysis = research.keyword_analysis || {};
|
||||
const primaryKeywords = keywordAnalysis.primary || [];
|
||||
const secondaryKeywords = keywordAnalysis.secondary || [];
|
||||
const contentAngles = research.suggested_angles || [];
|
||||
const searchIntent = keywordAnalysis.search_intent || 'informational';
|
||||
|
||||
// Simulate progress updates
|
||||
setTimeout(() => setGenerationProgress('Extracting keywords and content angles...'), 500);
|
||||
setTimeout(() => setGenerationProgress('Generating SEO-optimized titles with AI...'), 1500);
|
||||
|
||||
const result = await blogWriterApi.generateSEOTitles({
|
||||
research,
|
||||
outline: sections,
|
||||
primary_keywords: primaryKeywords,
|
||||
secondary_keywords: secondaryKeywords,
|
||||
content_angles: contentAngles,
|
||||
search_intent: searchIntent,
|
||||
word_count: sections.reduce((sum, s) => sum + (s.target_words || 0), 0)
|
||||
});
|
||||
|
||||
setGenerationProgress('Finalizing titles...');
|
||||
|
||||
if (result.success && result.titles) {
|
||||
setTimeout(() => {
|
||||
setGeneratedTitles(result.titles);
|
||||
setGenerationProgress('');
|
||||
if (onTitlesGenerated) {
|
||||
onTitlesGenerated(result.titles);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate SEO titles:', error);
|
||||
setGenerationProgress('');
|
||||
alert('Failed to generate SEO titles. Please try again.');
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsGenerating(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const getSectionSummary = () => {
|
||||
return sections.map(section => ({
|
||||
title: section.heading,
|
||||
wordCount: section.target_words || 0,
|
||||
subheadings: section.subheadings.length,
|
||||
keyPoints: section.key_points.length
|
||||
}));
|
||||
};
|
||||
|
||||
const sectionSummary = getSectionSummary();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Title Display */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
padding: '20px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 8px 0', color: '#333', fontSize: '18px' }}>
|
||||
📝 Blog Title
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '0',
|
||||
color: '#666',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '600px'
|
||||
}}>
|
||||
{(selectedTitle || 'No title selected').length > 150
|
||||
? (selectedTitle || 'No title selected').substring(0, 150) + '...'
|
||||
: (selectedTitle || 'No title selected')}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
position: 'relative'
|
||||
}}
|
||||
title="Open title suggestions. Click 'Generate 5 SEO-Optimized Titles' in the modal to create premium titles (50-65 characters) optimized for search engines using your research data and outline."
|
||||
>
|
||||
✨ ALwrity it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title Selection Modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '900px',
|
||||
width: '95%',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '32px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '2px solid #f3f4f6'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: '0 0 4px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600' }}>
|
||||
✨ ALwrity Title Suggestions
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Choose from research-based content angles, AI-generated titles, or create your own
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#9ca3af',
|
||||
padding: '4px',
|
||||
borderRadius: '6px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generate SEO Titles Button */}
|
||||
{research && sections.length > 0 && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<button
|
||||
onClick={handleGenerateSEOTitles}
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 24px',
|
||||
backgroundColor: isGenerating ? '#9ca3af' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s ease',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isGenerating) {
|
||||
e.currentTarget.style.backgroundColor = '#1565c0';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isGenerating) {
|
||||
e.currentTarget.style.backgroundColor = '#1976d2';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<span>⏳</span>
|
||||
<span>{generationProgress || 'Generating SEO Titles...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>✨</span>
|
||||
<span>Generate 5 SEO-Optimized Titles</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isGenerating && (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: '2px',
|
||||
marginTop: '12px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
backgroundColor: '#1976d2',
|
||||
borderRadius: '2px',
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
width: '100%'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
{isGenerating && generationProgress && (
|
||||
<p style={{
|
||||
margin: '8px 0 0 0',
|
||||
color: '#6b7280',
|
||||
fontSize: '13px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{generationProgress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title Options */}
|
||||
<div style={{ display: 'grid', gap: '24px' }}>
|
||||
{/* Generated SEO Titles */}
|
||||
{generatedTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#dcfce7',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🎯
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
SEO-Optimized Titles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Premium titles optimized for search engines (50-65 characters)
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#16a34a',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{generatedTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{generatedTitles.map((title, index) => (
|
||||
<button
|
||||
key={`seo-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #16a34a' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#f0fdf4' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Research Content Angles */}
|
||||
{researchTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🔍
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
Research Content Angles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Titles derived from your research data and content angles
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{researchTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{researchTitles.map((title, index) => (
|
||||
<button
|
||||
key={`research-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #1976d2' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#f0f9ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI-Generated Titles */}
|
||||
{aiGeneratedTitles.length > 0 && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#f3e5f5',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
🤖
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
AI-Generated Titles
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Creative titles generated by AI based on your research
|
||||
</p>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
backgroundColor: '#7b1fa2',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
{aiGeneratedTitles.length}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{aiGeneratedTitles.map((title, index) => (
|
||||
<button
|
||||
key={`ai-${index}`}
|
||||
onClick={() => handleTitleSelect(title)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '16px 20px',
|
||||
border: selectedTitle === title ? '2px solid #7b1fa2' : '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: selectedTitle === title ? '#faf5ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
fontSize: '15px',
|
||||
color: '#1f2937',
|
||||
transition: 'all 0.2s ease',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#d1d5db';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (selectedTitle !== title) {
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Title Input */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#fef3c7',
|
||||
borderRadius: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
}}>
|
||||
✏️
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
|
||||
Custom Title
|
||||
</h4>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
Create your own unique title
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="Enter your custom title..."
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '16px 20px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '15px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleCustomTitleSubmit()}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(25, 118, 210, 0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCustomTitleSubmit}
|
||||
disabled={!customTitle.trim()}
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
backgroundColor: customTitle.trim() ? '#1976d2' : '#d1d5db',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
cursor: customTitle.trim() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
minWidth: '120px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (customTitle.trim()) {
|
||||
e.currentTarget.style.backgroundColor = '#1565c0';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (customTitle.trim()) {
|
||||
e.currentTarget.style.backgroundColor = '#1976d2';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Use Title
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Information */}
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginTop: '24px'
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
|
||||
📋 Current Outline Summary
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
|
||||
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Details */}
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{sectionSummary.map((section, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
|
||||
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
|
||||
<span>{section.wordCount} words</span>
|
||||
<span>{section.subheadings} subheadings</span>
|
||||
<span>{section.keyPoints} key points</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '12px',
|
||||
marginTop: '24px',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnhancedTitleSelector;
|
||||
73
frontend/src/components/BlogWriter/HallucinationChecker.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import DiffPreview from './DiffPreview';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
interface HallucinationCheckerProps {
|
||||
buildFullMarkdown: () => string;
|
||||
buildUpdatedMarkdownForClaim: (claimText: string, supportingUrl?: string) => {
|
||||
original: string;
|
||||
updated: string;
|
||||
updatedMarkdown: string;
|
||||
};
|
||||
applyClaimFix: (claimText: string, supportingUrl?: string) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const HallucinationChecker: React.FC<HallucinationCheckerProps> = ({
|
||||
buildFullMarkdown,
|
||||
buildUpdatedMarkdownForClaim,
|
||||
applyClaimFix
|
||||
}) => {
|
||||
const [hallucinationResult, setHallucinationResult] = useState<any>(null);
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'runHallucinationCheck',
|
||||
description: 'Run hallucination detector on full draft and view claims',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
const content = buildFullMarkdown();
|
||||
const res = await apiClient.post('/api/blog/quality/hallucination-check', { text: content });
|
||||
const data = res.data;
|
||||
setHallucinationResult(data);
|
||||
return { success: true, total_claims: data?.total_claims };
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, result }: any) => {
|
||||
if (!result) return null;
|
||||
const claims = hallucinationResult?.claims || [];
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Hallucination Check</div>
|
||||
<div>Total claims: {hallucinationResult?.total_claims ?? 0}</div>
|
||||
<ul>
|
||||
{claims.slice(0, 5).map((c: any, i: number) => {
|
||||
const supporting = (c.supporting_sources && c.supporting_sources[0]?.url) || undefined;
|
||||
const { original, updated } = buildUpdatedMarkdownForClaim(c.text, supporting);
|
||||
return (
|
||||
<li key={i} style={{ marginBottom: 10 }}>
|
||||
<div style={{ marginBottom: 4 }}>[{c.assessment}] {c.text} (conf: {Math.round((c.confidence || 0)*100)/100})</div>
|
||||
{original && updated ? (
|
||||
<DiffPreview
|
||||
original={original}
|
||||
updated={updated}
|
||||
onApply={() => { applyClaimFix(c.text, supporting); respond?.('applied'); }}
|
||||
onDiscard={() => { respond?.('discarded'); }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>No matching sentence found for preview.</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button onClick={() => respond?.('ack')}>Close</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
|
||||
export default HallucinationChecker;
|
||||
27
frontend/src/components/BlogWriter/KeywordInputForm.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import ResearchPollingHandler from './ResearchPollingHandler';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface KeywordInputFormProps {
|
||||
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
|
||||
onResearchComplete?: (researchData: BlogResearchResponse) => void;
|
||||
onTaskStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
|
||||
// This component is now a lightweight wrapper
|
||||
// The actual keyword input form is handled by ResearchAction component
|
||||
// Polling is handled by ResearchPollingHandler in ResearchAction
|
||||
// This component exists for backward compatibility but doesn't create unnecessary polling hooks
|
||||
|
||||
// Note: If onTaskStart is called, it should use the researchPolling from parent
|
||||
// (passed via CopilotKitComponents), not create a new polling instance here
|
||||
|
||||
return null; // No UI needed - ResearchAction handles everything
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
113
frontend/src/components/BlogWriter/ManualContentButton.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, CircularProgress } from '@mui/material';
|
||||
import { mediumBlogApi } from '../../services/blogWriterApi';
|
||||
import { BlogOutlineSection, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
interface ManualContentButtonProps {
|
||||
/**
|
||||
* The confirmed outline sections
|
||||
*/
|
||||
outline: BlogOutlineSection[];
|
||||
/**
|
||||
* The research data
|
||||
*/
|
||||
research: BlogResearchResponse;
|
||||
/**
|
||||
* Blog title (optional)
|
||||
*/
|
||||
blogTitle?: string;
|
||||
/**
|
||||
* Existing sections content (optional)
|
||||
*/
|
||||
sections?: Record<string, string>;
|
||||
/**
|
||||
* Callback when content generation starts
|
||||
*/
|
||||
onGenerationStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual content generation button that works independently of CopilotKit
|
||||
* Triggers medium blog generation via mediumBlogApi
|
||||
*/
|
||||
export const ManualContentButton: React.FC<ManualContentButtonProps> = ({
|
||||
outline,
|
||||
research,
|
||||
blogTitle,
|
||||
sections,
|
||||
onGenerationStart,
|
||||
}) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!outline || outline.length === 0) {
|
||||
alert('Please confirm an outline first before generating content.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!research) {
|
||||
alert('Research data is required for content generation.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
outline,
|
||||
research,
|
||||
title: blogTitle || outline[0]?.heading || 'Blog Post',
|
||||
existing_sections: sections || {},
|
||||
};
|
||||
|
||||
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
|
||||
|
||||
if (task_id) {
|
||||
onGenerationStart?.(task_id);
|
||||
} else {
|
||||
throw new Error('Failed to start content generation - no task ID returned');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
alert(`Content generation failed: ${errorMessage}`);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Generate Blog Content</h3>
|
||||
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px' }}>
|
||||
Generate full content for all sections in your confirmed outline.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleGenerate}
|
||||
disabled={!outline || outline.length === 0 || !research || isGenerating}
|
||||
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : null}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
py: 1.5,
|
||||
px: 4,
|
||||
}}
|
||||
>
|
||||
{isGenerating ? 'Generating Content...' : '📝 Generate Content'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '12px 0 0 0', color: '#d32f2f', fontSize: '14px' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualContentButton;
|
||||
|
||||
111
frontend/src/components/BlogWriter/ManualOutlineButton.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, CircularProgress } from '@mui/material';
|
||||
|
||||
interface ManualOutlineButtonProps {
|
||||
/**
|
||||
* Ref to OutlineGenerator component with generateNow() method
|
||||
*/
|
||||
outlineGenRef: React.RefObject<{
|
||||
generateNow: () => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
task_id?: string;
|
||||
cached?: boolean;
|
||||
outline?: any[];
|
||||
title_options?: string[];
|
||||
}>
|
||||
}>;
|
||||
/**
|
||||
* Whether research is available (required for outline generation)
|
||||
*/
|
||||
hasResearch: boolean;
|
||||
/**
|
||||
* Callback when outline generation starts
|
||||
*/
|
||||
onGenerationStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual outline generation button that works independently of CopilotKit
|
||||
* Calls the generateNow() method from OutlineGenerator ref
|
||||
*/
|
||||
export const ManualOutlineButton: React.FC<ManualOutlineButtonProps> = ({
|
||||
outlineGenRef,
|
||||
hasResearch,
|
||||
onGenerationStart,
|
||||
}) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!hasResearch) {
|
||||
alert('Please complete research first before generating an outline.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!outlineGenRef.current) {
|
||||
alert('Outline generator is not available. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await outlineGenRef.current.generateNow();
|
||||
|
||||
if (result.success) {
|
||||
if (result.cached && result.outline) {
|
||||
// Handle cached result - outline is already available, no need to poll
|
||||
console.log('[ManualOutlineButton] Cached outline used', { sections: result.outline.length });
|
||||
// The outline should be set by the parent component handling the cache
|
||||
} else if (result.task_id) {
|
||||
onGenerationStart?.(result.task_id);
|
||||
}
|
||||
} else {
|
||||
setError(result.message || 'Failed to generate outline');
|
||||
alert(result.message || 'Failed to generate outline. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
alert(`Outline generation failed: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', color: '#333' }}>Create Your Outline</h3>
|
||||
<p style={{ margin: '0 0 20px 0', color: '#666', fontSize: '14px' }}>
|
||||
Generate an AI-powered outline based on your research.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleGenerate}
|
||||
disabled={!hasResearch || isGenerating}
|
||||
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : null}
|
||||
sx={{
|
||||
minWidth: 200,
|
||||
py: 1.5,
|
||||
px: 4,
|
||||
}}
|
||||
>
|
||||
{isGenerating ? 'Generating Outline...' : '🧩 Generate Outline'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p style={{ margin: '12px 0 0 0', color: '#d32f2f', fontSize: '14px' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualOutlineButton;
|
||||
|
||||
184
frontend/src/components/BlogWriter/ManualResearchForm.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
|
||||
interface ManualResearchFormProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual research form component that works independently of CopilotKit
|
||||
* Extracted from ResearchAction.tsx for use when CopilotKit is unavailable
|
||||
*/
|
||||
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Refs for form inputs (uncontrolled, avoids typing issues)
|
||||
const keywordsRef = useRef<HTMLInputElement | null>(null);
|
||||
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
setCurrentMessage(message);
|
||||
},
|
||||
onComplete: (result) => {
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
result.industry || 'General',
|
||||
result.target_audience || 'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const keywords = (keywordsRef.current?.value || '').trim();
|
||||
const blogLength = blogLengthRef.current?.value || '1000';
|
||||
|
||||
if (!keywords) {
|
||||
alert('Please enter keywords or a topic for research.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map(k => k.trim()).filter(Boolean)
|
||||
: [keywords];
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
|
||||
if (cachedResult) {
|
||||
onResearchComplete?.(cachedResult);
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: 'General',
|
||||
target_audience: 'General',
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
setShowProgressModal(true);
|
||||
polling.startPolling(task_id);
|
||||
} catch (error) {
|
||||
console.error('Research failed:', error);
|
||||
alert(`Research failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Let's Research Your Blog Topic</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Keywords or Topic *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="research-keywords-input"
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
ref={keywordsRef}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
opacity: isSubmitting ? 0.6 : 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
|
||||
<select
|
||||
id="research-blog-length-select"
|
||||
defaultValue="1000"
|
||||
ref={blogLengthRef}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
opacity: isSubmitting ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<option value="500">500 words (Short blog)</option>
|
||||
<option value="1000">1000 words (Medium blog)</option>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000 words (Comprehensive blog)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: isSubmitting ? '#ccc' : '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||
opacity: isSubmitting ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? '⏳ Starting Research...' : '🚀 Start Research'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showProgressModal && (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal}
|
||||
title="Research in progress"
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => setShowProgressModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManualResearchForm;
|
||||
|
||||
747
frontend/src/components/BlogWriter/OutlineFeedbackForm.tsx
Normal file
@@ -0,0 +1,747 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi, mediumBlogApi } from '../../services/blogWriterApi';
|
||||
import { useMediumGenerationPolling } from '../../hooks/usePolling';
|
||||
|
||||
// Simple toast notification function
|
||||
const showToast = (message: string, type: 'success' | 'error' = 'success') => {
|
||||
const toast = document.createElement('div');
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
background-color: ${type === 'success' ? '#4caf50' : '#f44336'};
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(toast);
|
||||
}, 300);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface OutlineFeedbackFormProps {
|
||||
outline: BlogOutlineSection[];
|
||||
research: BlogResearchResponse;
|
||||
onOutlineConfirmed: () => void;
|
||||
onOutlineRefined: (feedback: string) => void;
|
||||
onMediumGenerationStarted?: (taskId: string) => void;
|
||||
onMediumGenerationTriggered?: () => void;
|
||||
sections?: Record<string, string>;
|
||||
blogTitle?: string;
|
||||
onFlowAnalysisComplete?: (analysis: any) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
|
||||
// Separate component to manage feedback form state
|
||||
const FeedbackForm: React.FC<{
|
||||
prompt?: string;
|
||||
onSubmit: (data: { feedback: string; action: 'refine' | 'confirm' }) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ prompt, onSubmit, onCancel }) => {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [action, setAction] = useState<'refine' | 'confirm'>('refine');
|
||||
const hasValidInput = feedback.trim().length > 0 || action === 'confirm';
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasValidInput) {
|
||||
onSubmit({ feedback: feedback.trim(), action });
|
||||
} else {
|
||||
window.alert('Please provide feedback or confirm the outline.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
📝 Outline Review & Feedback
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{prompt || 'Please review the generated outline and provide your feedback:'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#333'
|
||||
}}>
|
||||
What would you like to do? *
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '12px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="action"
|
||||
value="refine"
|
||||
checked={action === 'refine'}
|
||||
onChange={(e) => setAction(e.target.value as 'refine' | 'confirm')}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<span style={{ fontSize: '14px' }}>🔧 Refine/Edit Outline</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="action"
|
||||
value="confirm"
|
||||
checked={action === 'confirm'}
|
||||
onChange={(e) => setAction(e.target.value as 'refine' | 'confirm')}
|
||||
style={{ margin: 0 }}
|
||||
/>
|
||||
<span style={{ fontSize: '14px' }}>✅ Confirm & Generate Content</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action === 'refine' && (
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
color: '#333'
|
||||
}}>
|
||||
Your Feedback & Suggestions *
|
||||
</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="e.g., Add a section about implementation challenges, Remove the conclusion section, Make the introduction more engaging, Change the order of sections..."
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '100px',
|
||||
resize: 'vertical',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
autoFocus
|
||||
spellCheck="true"
|
||||
/>
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
💡 Be specific about what you want to change. The AI will use your feedback to improve the outline.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action === 'confirm' && (
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#e8f5e8',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #4caf50'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#2e7d32', fontSize: '14px' }}>
|
||||
✅ Ready to generate content! Click "Submit" to proceed with content generation for all sections.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!hasValidInput}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 16px',
|
||||
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: hasValidInput ? 'pointer' : 'not-allowed',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
>
|
||||
{action === 'refine' ? '🔧 Refine Outline' : '✅ Confirm & Generate Content'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#666',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
outline,
|
||||
research,
|
||||
navigateToPhase,
|
||||
onOutlineConfirmed,
|
||||
onOutlineRefined,
|
||||
onMediumGenerationStarted,
|
||||
onMediumGenerationTriggered,
|
||||
sections,
|
||||
blogTitle,
|
||||
onFlowAnalysisComplete
|
||||
}) => {
|
||||
|
||||
// Refine outline action with HITL
|
||||
useCopilotActionTyped({
|
||||
name: 'refineOutline',
|
||||
description: 'Refine the outline based on user feedback',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
handler: async ({ prompt, feedback }: { prompt?: string; feedback?: string }) => {
|
||||
// Validate input
|
||||
if (!feedback || feedback.trim().length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Please provide specific feedback for outline refinement.',
|
||||
suggestion: 'Try describing what you want to change, add, or remove from the outline.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!research) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No research data available for outline refinement.',
|
||||
suggestion: 'Please complete research first before refining the outline.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a refined outline request with user feedback
|
||||
const refineRequest = {
|
||||
research: research,
|
||||
current_outline: outline,
|
||||
user_feedback: feedback.trim(),
|
||||
word_count: 1500
|
||||
};
|
||||
|
||||
// Start async outline refinement
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration(refineRequest);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔧 Outline refinement started based on your feedback! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id,
|
||||
next_step_suggestion: 'The outline is being refined based on your feedback. You can monitor progress below.'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Outline refinement error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Outline refinement failed: ${errorMessage}`,
|
||||
suggestion: 'Try providing more specific feedback or ask me to help clarify your requirements.'
|
||||
};
|
||||
}
|
||||
},
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Outline refinement completed! Check the progress below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ffc107'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#856404', fontWeight: '500' }}>
|
||||
⏳ Refining outline based on your feedback...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedbackForm
|
||||
prompt={args.prompt}
|
||||
onSubmit={(formData) => {
|
||||
if (formData.action === 'confirm') {
|
||||
onOutlineConfirmed();
|
||||
} else {
|
||||
onOutlineRefined(formData.feedback);
|
||||
}
|
||||
respond?.(JSON.stringify(formData));
|
||||
}}
|
||||
onCancel={() => respond?.('CANCEL')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Outline confirmation action
|
||||
useCopilotActionTyped({
|
||||
name: 'confirmOutlineAndGenerateContent',
|
||||
description: 'Confirm the outline and mark it as ready for content generation. This does NOT automatically generate content - it only confirms the outline.',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Validate that we have an outline to confirm
|
||||
if (!outline || outline.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No outline available to confirm.',
|
||||
suggestion: 'Please generate an outline first before confirming.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to content phase when outline is confirmed
|
||||
navigateToPhase?.('content');
|
||||
|
||||
onOutlineConfirmed();
|
||||
|
||||
// If research specifies a short/medium blog (<=1000), kick off medium generation
|
||||
const target = Number(
|
||||
research?.keyword_analysis?.blog_length ||
|
||||
(research as any)?.word_count_target ||
|
||||
localStorage.getItem('blog_length_target') ||
|
||||
0
|
||||
);
|
||||
|
||||
if (target && target <= 1000) {
|
||||
// Check cache first (shared utility)
|
||||
const { blogWriterCache } = await import('../../services/blogWriterCache');
|
||||
const outlineIds = outline.map(s => String(s.id));
|
||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||
|
||||
if (cachedContent) {
|
||||
console.log('[OutlineFeedbackForm] Using cached content', { sections: Object.keys(cachedContent).length });
|
||||
// Content is already cached, skip API call
|
||||
return {
|
||||
success: true,
|
||||
message: 'Content is already available from cache.',
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
// Show modal immediately when medium generation is triggered
|
||||
onMediumGenerationTriggered?.();
|
||||
// Build payload for medium generation
|
||||
const payload = {
|
||||
title: (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || outline[0]?.heading || 'Untitled',
|
||||
sections: outline.map(s => ({
|
||||
id: s.id,
|
||||
heading: s.heading,
|
||||
keyPoints: s.key_points,
|
||||
subheadings: s.subheadings,
|
||||
keywords: s.keywords,
|
||||
targetWords: s.target_words,
|
||||
references: s.references,
|
||||
})),
|
||||
globalTargetWords: target,
|
||||
researchKeywords: research.original_keywords || research.keyword_analysis?.primary || [], // Use original research keywords for better caching
|
||||
};
|
||||
|
||||
const { task_id } = await mediumBlogApi.startMediumGeneration(payload as any);
|
||||
|
||||
// Notify parent to start polling for the medium generation task
|
||||
onMediumGenerationStarted?.(task_id);
|
||||
|
||||
// Poll once immediately to check for immediate failures (e.g., subscription errors)
|
||||
try {
|
||||
const initialStatus = await mediumBlogApi.pollMediumGeneration(task_id);
|
||||
|
||||
// Check if task already failed with subscription error
|
||||
if (initialStatus.status === 'failed' && (initialStatus.error_status === 429 || initialStatus.error_status === 402)) {
|
||||
const errorData = initialStatus.error_data || {};
|
||||
const errorMessage = errorData.message || errorData.error || initialStatus.error || 'Subscription limit exceeded';
|
||||
|
||||
// Return error to CopilotKit so it shows in chat
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Medium generation failed: ${errorMessage}`,
|
||||
error: errorMessage,
|
||||
error_type: 'subscription_limit',
|
||||
provider: errorData.provider || 'unknown',
|
||||
suggestion: 'Please renew your subscription to continue generating content.',
|
||||
action_taken: 'outline_confirmed_medium_generation_failed'
|
||||
};
|
||||
}
|
||||
|
||||
// Task started successfully, continue polling in background
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline confirmed. Medium generation started (Task: ${task_id}). You can monitor progress in the modal.`,
|
||||
task_id,
|
||||
action_taken: 'outline_confirmed_medium_generation_started'
|
||||
};
|
||||
} catch (pollError: any) {
|
||||
// Check if polling error is a subscription error (HTTP 429/402)
|
||||
if (pollError?.response?.status === 429 || pollError?.response?.status === 402) {
|
||||
const errorData = pollError.response?.data || {};
|
||||
const errorMessage = errorData.message || errorData.error || 'Subscription limit exceeded';
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Medium generation failed: ${errorMessage}`,
|
||||
error: errorMessage,
|
||||
error_type: 'subscription_limit',
|
||||
provider: errorData.provider || 'unknown',
|
||||
suggestion: 'Please renew your subscription to continue generating content.',
|
||||
action_taken: 'outline_confirmed_medium_generation_failed'
|
||||
};
|
||||
}
|
||||
|
||||
// Other polling errors - still return success since task was started
|
||||
// The polling will handle the error in the background
|
||||
console.warn('Initial poll check failed, but task was started:', pollError);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline confirmed. Medium generation started (Task: ${task_id}). You can monitor progress in the modal.`,
|
||||
task_id,
|
||||
action_taken: 'outline_confirmed_medium_generation_started'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline confirmed! Ready to generate content for ${outline.length} sections.`,
|
||||
next_step_suggestion: 'Now you can choose to generate content for individual sections or all sections at once using the available suggestions.',
|
||||
outline_sections: outline.length,
|
||||
action_taken: 'outline_confirmed_only'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Outline confirmation error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Outline confirmation failed: ${errorMessage}`,
|
||||
suggestion: 'Please try again or contact support if the problem persists.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Chat with Outline action
|
||||
useCopilotActionTyped({
|
||||
name: 'chatWithOutline',
|
||||
description: 'Chat with the outline to get insights, summaries, and interesting questions about the content structure',
|
||||
parameters: [
|
||||
{ name: 'question', type: 'string', description: 'Question about the outline or content structure', required: false }
|
||||
],
|
||||
handler: async ({ question }: { question?: string }) => {
|
||||
if (!outline || outline.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No outline available to chat with.',
|
||||
suggestion: 'Please generate an outline first before chatting about it.'
|
||||
};
|
||||
}
|
||||
|
||||
if (!research) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No research data available for outline discussion.',
|
||||
suggestion: 'Please complete research first before chatting about the outline.'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Provide comprehensive outline and research context
|
||||
const outlineContext = {
|
||||
totalSections: outline.length,
|
||||
sections: outline.map(section => ({
|
||||
heading: section.heading,
|
||||
subheadings: section.subheadings,
|
||||
keyPoints: section.key_points,
|
||||
targetWords: section.target_words
|
||||
})),
|
||||
researchSummary: {
|
||||
sources: research.sources?.length || 0,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational',
|
||||
contentAngles: research.suggested_angles || []
|
||||
},
|
||||
totalTargetWords: outline.reduce((sum, section) => sum + (section.target_words || 0), 0)
|
||||
};
|
||||
|
||||
// If no specific question, provide a summary and interesting questions
|
||||
if (!question) {
|
||||
const summary = `I can see you have a well-structured outline with ${outlineContext.totalSections} sections targeting ${outlineContext.totalTargetWords} words total. The outline covers: ${outline.map(s => s.heading).join(', ')}.`;
|
||||
|
||||
const interestingQuestions = [
|
||||
"What's the main narrative flow of this outline?",
|
||||
"How does each section build upon the previous one?",
|
||||
"What are the key takeaways readers will get from each section?",
|
||||
"How well does this outline address the search intent: " + outlineContext.researchSummary.searchIntent + "?",
|
||||
"What additional sections might strengthen this content?",
|
||||
"How can we improve the engagement factor of each section?"
|
||||
];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${summary}\n\nHere are some interesting questions to explore:\n${interestingQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n')}`,
|
||||
outlineContext: outlineContext,
|
||||
next_step_suggestion: 'Ask me any specific questions about the outline structure, content flow, or how to improve it.'
|
||||
};
|
||||
}
|
||||
|
||||
// Handle specific questions about the outline
|
||||
return {
|
||||
success: true,
|
||||
message: `Great question about the outline! Based on the current structure and research data, I can help you analyze and improve the outline.`,
|
||||
outlineContext: outlineContext,
|
||||
question: question,
|
||||
next_step_suggestion: 'Feel free to ask more specific questions about sections, flow, or content strategy.'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Chat with outline error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to chat with outline: ${errorMessage}`,
|
||||
suggestion: 'Please try again or ask a more specific question about the outline.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Flow Analysis Actions
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeContentQuality',
|
||||
description: 'Analyze the flow and quality of blog content to get improvement suggestions (basic analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
try {
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No content available for analysis. Please generate content first.',
|
||||
suggestion: 'Generate content for your blog sections before running quality analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare sections data for analysis
|
||||
const sectionsData = Object.entries(sections).map(([id, content]: [string, any]) => {
|
||||
const outlineSection = outline.find(s => s.id === id);
|
||||
return {
|
||||
id,
|
||||
heading: outlineSection?.heading || 'Untitled Section',
|
||||
content: typeof content === 'string' ? content : (content?.content || '')
|
||||
};
|
||||
});
|
||||
|
||||
if (sectionsData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No valid sections found for analysis.',
|
||||
suggestion: 'Ensure your blog has generated content before running analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Call basic flow analysis API
|
||||
const result = await blogWriterApi.analyzeFlowBasic({
|
||||
title: blogTitle || 'Untitled Blog',
|
||||
sections: sectionsData
|
||||
});
|
||||
|
||||
if (result.success && result.analysis) {
|
||||
// Notify parent component of analysis completion
|
||||
onFlowAnalysisComplete?.(result.analysis);
|
||||
|
||||
const analysis = result.analysis;
|
||||
const overallFlow = Math.round(analysis.overall_flow_score * 100);
|
||||
const overallConsistency = Math.round(analysis.overall_consistency_score * 100);
|
||||
const overallProgression = Math.round(analysis.overall_progression_score * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Content quality analysis completed! Your blog has an overall flow score of ${overallFlow}%, consistency of ${overallConsistency}%, and progression of ${overallProgression}%.`,
|
||||
analysis: {
|
||||
overall_scores: {
|
||||
flow: overallFlow,
|
||||
consistency: overallConsistency,
|
||||
progression: overallProgression
|
||||
},
|
||||
sections: analysis.sections.map((s: any) => ({
|
||||
heading: s.heading,
|
||||
flow: Math.round(s.flow_score * 100),
|
||||
consistency: Math.round(s.consistency_score * 100),
|
||||
progression: Math.round(s.progression_score * 100),
|
||||
suggestions: s.suggestions
|
||||
})),
|
||||
overall_suggestions: analysis.overall_suggestions
|
||||
},
|
||||
next_step_suggestion: 'Use "🔍 Deep Content Analysis" for detailed, section-by-section analysis with more specific recommendations.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Content quality analysis failed.',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
suggestion: 'Please try again or check if your content is properly generated.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Content quality analysis error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to analyze content quality: ${errorMessage}`,
|
||||
suggestion: 'Please try again or ensure your content is properly generated.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeContentQualityAdvanced',
|
||||
description: 'Get detailed, section-by-section analysis of content quality and flow (advanced analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
try {
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No content available for advanced analysis. Please generate content first.',
|
||||
suggestion: 'Generate content for your blog sections before running advanced analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare sections data for analysis
|
||||
const sectionsData = Object.entries(sections).map(([id, content]: [string, any]) => {
|
||||
const outlineSection = outline.find(s => s.id === id);
|
||||
return {
|
||||
id,
|
||||
heading: outlineSection?.heading || 'Untitled Section',
|
||||
content: typeof content === 'string' ? content : (content?.content || '')
|
||||
};
|
||||
});
|
||||
|
||||
if (sectionsData.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No valid sections found for advanced analysis.',
|
||||
suggestion: 'Ensure your blog has generated content before running analysis.'
|
||||
};
|
||||
}
|
||||
|
||||
// Call advanced flow analysis API
|
||||
const result = await blogWriterApi.analyzeFlowAdvanced({
|
||||
title: blogTitle || 'Untitled Blog',
|
||||
sections: sectionsData
|
||||
});
|
||||
|
||||
if (result.success && result.analysis) {
|
||||
// Notify parent component of analysis completion
|
||||
onFlowAnalysisComplete?.(result.analysis);
|
||||
|
||||
const analysis = result.analysis;
|
||||
const overallFlow = Math.round(analysis.overall_flow_score * 100);
|
||||
const overallConsistency = Math.round(analysis.overall_consistency_score * 100);
|
||||
const overallProgression = Math.round(analysis.overall_progression_score * 100);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Advanced content analysis completed! Your blog has an overall flow score of ${overallFlow}%, consistency of ${overallConsistency}%, and progression of ${overallProgression}%.`,
|
||||
analysis: {
|
||||
overall_scores: {
|
||||
flow: overallFlow,
|
||||
consistency: overallConsistency,
|
||||
progression: overallProgression
|
||||
},
|
||||
sections: analysis.sections.map((s: any) => ({
|
||||
heading: s.heading,
|
||||
flow: Math.round(s.flow_score * 100),
|
||||
consistency: Math.round(s.consistency_score * 100),
|
||||
progression: Math.round(s.progression_score * 100),
|
||||
detailed_analysis: s.detailed_analysis,
|
||||
suggestions: s.suggestions
|
||||
}))
|
||||
},
|
||||
next_step_suggestion: 'Review the detailed analysis and implement the suggested improvements to enhance your content quality.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Advanced content analysis failed.',
|
||||
error: result.error || 'Unknown error occurred',
|
||||
suggestion: 'Please try again or check if your content is properly generated.'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Advanced content analysis error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to perform advanced content analysis: ${errorMessage}`,
|
||||
suggestion: 'Please try again or ensure your content is properly generated.'
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot actions
|
||||
};
|
||||
|
||||
export default OutlineFeedbackForm;
|
||||
177
frontend/src/components/BlogWriter/OutlineGenerator.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { blogWriterCache } from '../../services/blogWriterCache';
|
||||
|
||||
interface OutlineGeneratorProps {
|
||||
research: BlogResearchResponse | null;
|
||||
onTaskStart: (taskId: string) => void;
|
||||
onPollingStart: (taskId: string) => void;
|
||||
onModalShow?: () => void; // Callback to show progress modal immediately
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
onOutlineCreated?: (outline: any[], titleOptions?: any[]) => void; // Callback when outline is created/found (for cached outlines)
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
research,
|
||||
onTaskStart,
|
||||
onPollingStart,
|
||||
onModalShow,
|
||||
navigateToPhase,
|
||||
onOutlineCreated
|
||||
}, ref) => {
|
||||
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
||||
useImperativeHandle(ref, () => ({
|
||||
generateNow: async () => {
|
||||
if (!research) {
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||
|
||||
if (cachedOutline) {
|
||||
console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length });
|
||||
// Return cached result - caller should handle setting outline state
|
||||
return {
|
||||
success: true,
|
||||
cached: true,
|
||||
outline: cachedOutline.outline,
|
||||
title_options: cachedOutline.title_options
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
onModalShow?.();
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
onTaskStart(task_id);
|
||||
onPollingStart(task_id);
|
||||
return { success: true, task_id };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
}
|
||||
}));
|
||||
useCopilotActionTyped({
|
||||
name: 'generateOutline',
|
||||
description: 'Generate outline from research results using AI analysis',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
if (!research) {
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
|
||||
// Check cache first (shared utility)
|
||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||
|
||||
if (cachedOutline) {
|
||||
console.log('[OutlineGenerator] Using cached outline from CopilotKit action', { sections: cachedOutline.outline.length });
|
||||
|
||||
// Navigate to outline phase when cached outline is found
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Update parent state with cached outline (same as header button does)
|
||||
if (onOutlineCreated) {
|
||||
onOutlineCreated(cachedOutline.outline, cachedOutline.title_options);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline already exists! ${cachedOutline.outline.length} sections loaded from cache.`,
|
||||
cached: true,
|
||||
outline: cachedOutline.outline,
|
||||
title_options: cachedOutline.title_options
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to outline phase when outline generation starts
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Show progress modal immediately when user clicks "Create outline"
|
||||
onModalShow?.();
|
||||
|
||||
// Start async outline generation
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
|
||||
// Start polling immediately after getting task_id
|
||||
// This ensures we catch progress messages from the very beginning
|
||||
onTaskStart(task_id);
|
||||
onPollingStart(task_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🧩 Outline generation started! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Outline generation failed:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// Provide more specific error messages based on the error type
|
||||
let userMessage = '❌ Outline generation failed. ';
|
||||
if (errorMessage.includes('503') || errorMessage.includes('overloaded')) {
|
||||
userMessage += 'The AI service is temporarily overloaded. Please try again in a few minutes.';
|
||||
} else if (errorMessage.includes('timeout')) {
|
||||
userMessage += 'The request timed out. Please try again.';
|
||||
} else if (errorMessage.includes('Invalid outline structure')) {
|
||||
userMessage += 'The AI generated an invalid response. Please try again with different research data.';
|
||||
} else {
|
||||
userMessage += `${errorMessage}. Please try again or contact support if the problem persists.`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: userMessage
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #388e3c',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#388e3c' }}>🧩 Generating Outline</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing research results and content angles...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Structuring content based on keyword analysis...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Creating logical flow and section hierarchy...</p>
|
||||
<p style={{ margin: '0' }}>• Optimizing for SEO and reader engagement...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
});
|
||||
|
||||
export default OutlineGenerator;
|
||||
561
frontend/src/components/BlogWriter/OutlineIntelligenceChips.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
||||
|
||||
interface OutlineIntelligenceChipsProps {
|
||||
sections: BlogOutlineSection[];
|
||||
sourceMappingStats?: SourceMappingStats | null;
|
||||
groundingInsights?: GroundingInsights | null;
|
||||
optimizationResults?: OptimizationResults | null;
|
||||
researchCoverage?: ResearchCoverage | null;
|
||||
}
|
||||
|
||||
const OutlineIntelligenceChips: React.FC<OutlineIntelligenceChipsProps> = ({
|
||||
sections,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage
|
||||
}) => {
|
||||
const [activeModal, setActiveModal] = useState<string | null>(null);
|
||||
|
||||
const getConfidenceColor = (score: number) => {
|
||||
if (score >= 0.8) return '#4caf50'; // Green
|
||||
if (score >= 0.6) return '#ff9800'; // Orange
|
||||
return '#f44336'; // Red
|
||||
};
|
||||
|
||||
const getQualityGrade = (score: number) => {
|
||||
if (score >= 9) return { grade: 'A+', color: '#4caf50' };
|
||||
if (score >= 8) return { grade: 'A', color: '#4caf50' };
|
||||
if (score >= 7) return { grade: 'B+', color: '#8bc34a' };
|
||||
if (score >= 6) return { grade: 'B', color: '#ff9800' };
|
||||
if (score >= 5) return { grade: 'C', color: '#ff9800' };
|
||||
return { grade: 'D', color: '#f44336' };
|
||||
};
|
||||
|
||||
const chips = [
|
||||
{
|
||||
id: 'research',
|
||||
label: 'Research Data',
|
||||
icon: '📊',
|
||||
color: '#e3f2fd',
|
||||
textColor: '#1976d2',
|
||||
data: researchCoverage,
|
||||
description: 'How well your research data is being utilized',
|
||||
metrics: researchCoverage ? [
|
||||
{ label: 'Sources Used', value: researchCoverage.sources_utilized, color: '#1976d2' },
|
||||
{ label: 'Content Gaps', value: researchCoverage.content_gaps_identified, color: '#ff9800' },
|
||||
{ label: 'Advantages', value: researchCoverage.competitive_advantages.length, color: '#4caf50' }
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'mapping',
|
||||
label: 'Source Mapping',
|
||||
icon: '🔗',
|
||||
color: '#f3e5f5',
|
||||
textColor: '#7b1fa2',
|
||||
data: sourceMappingStats,
|
||||
description: 'Intelligence in mapping sources to sections',
|
||||
metrics: sourceMappingStats ? [
|
||||
{ label: 'Mapped', value: sourceMappingStats.total_sources_mapped, color: '#7b1fa2' },
|
||||
{ label: 'Coverage', value: `${Math.round(sourceMappingStats.coverage_percentage)}%`, color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100) },
|
||||
{ label: 'Relevance', value: `${Math.round(sourceMappingStats.average_relevance_score * 100)}%`, color: getConfidenceColor(sourceMappingStats.average_relevance_score) },
|
||||
{ label: 'High Conf', value: sourceMappingStats.high_confidence_mappings, color: '#4caf50' }
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'grounding',
|
||||
label: 'Grounding Insights',
|
||||
icon: '🧠',
|
||||
color: '#e8f5e8',
|
||||
textColor: '#2e7d32',
|
||||
data: groundingInsights,
|
||||
description: 'AI-powered insights from search grounding',
|
||||
metrics: groundingInsights ? [
|
||||
{
|
||||
label: 'Confidence',
|
||||
value: groundingInsights.confidence_analysis ? `${Math.round(groundingInsights.confidence_analysis.average_confidence * 100)}%` : 'N/A',
|
||||
color: groundingInsights.confidence_analysis ? getConfidenceColor(groundingInsights.confidence_analysis.average_confidence) : '#666'
|
||||
},
|
||||
{
|
||||
label: 'Authority',
|
||||
value: groundingInsights.authority_analysis ? `${Math.round(groundingInsights.authority_analysis.average_authority_score * 100)}%` : 'N/A',
|
||||
color: groundingInsights.authority_analysis ? getConfidenceColor(groundingInsights.authority_analysis.average_authority_score) : '#666'
|
||||
},
|
||||
{
|
||||
label: 'Coverage',
|
||||
value: groundingInsights.content_relationships ? `${Math.round(groundingInsights.content_relationships.concept_coverage_score * 100)}%` : 'N/A',
|
||||
color: groundingInsights.content_relationships ? getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score) : '#666'
|
||||
}
|
||||
] : []
|
||||
},
|
||||
{
|
||||
id: 'optimization',
|
||||
label: 'Optimization',
|
||||
icon: '🎯',
|
||||
color: '#fff3e0',
|
||||
textColor: '#f57c00',
|
||||
data: optimizationResults,
|
||||
description: 'AI optimization and quality assessment',
|
||||
metrics: optimizationResults ? [
|
||||
{
|
||||
label: 'Quality',
|
||||
value: `${optimizationResults.overall_quality_score}/10`,
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
},
|
||||
{
|
||||
label: 'Grade',
|
||||
value: getQualityGrade(optimizationResults.overall_quality_score).grade,
|
||||
color: getQualityGrade(optimizationResults.overall_quality_score).color
|
||||
},
|
||||
{
|
||||
label: 'Focus',
|
||||
value: optimizationResults.optimization_focus,
|
||||
color: '#f57c00'
|
||||
},
|
||||
{
|
||||
label: 'Improvements',
|
||||
value: optimizationResults.improvements_made.length,
|
||||
color: '#4caf50'
|
||||
}
|
||||
] : []
|
||||
}
|
||||
];
|
||||
|
||||
const renderModal = (chipId: string) => {
|
||||
const chip = chips.find(c => c.id === chipId);
|
||||
if (!chip || !chip.data) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '95%',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
paddingBottom: '16px',
|
||||
borderBottom: '2px solid #f3f4f6'
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{ margin: '0 0 4px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '28px' }}>{chip.icon}</span>
|
||||
{chip.label}
|
||||
</h2>
|
||||
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
|
||||
{chip.description}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveModal(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '28px',
|
||||
cursor: 'pointer',
|
||||
color: '#9ca3af',
|
||||
padding: '4px',
|
||||
borderRadius: '6px',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#f3f4f6';
|
||||
e.currentTarget.style.color = '#374151';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = '#9ca3af';
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div style={{ color: '#333' }}>
|
||||
{chipId === 'research' && researchCoverage && (
|
||||
<div>
|
||||
{/* Key Metrics */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Research Utilization Metrics</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#1976d2', marginBottom: '8px' }}>
|
||||
{researchCoverage.sources_utilized}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Sources Utilized</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Research sources actively used in outline generation
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#ff9800', marginBottom: '8px' }}>
|
||||
{researchCoverage.content_gaps_identified}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Content Gaps Identified</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Missing topics that could strengthen your content
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{researchCoverage.competitive_advantages.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Competitive Advantages</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Unique angles identified from research
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Competitive Advantages */}
|
||||
{researchCoverage.competitive_advantages.length > 0 && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Key Competitive Advantages</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{researchCoverage.competitive_advantages.map((advantage, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e8f5e8',
|
||||
color: '#388e3c',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #c8e6c9'
|
||||
}}>
|
||||
{advantage}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'mapping' && sourceMappingStats && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Source Mapping Intelligence</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#7b1fa2', marginBottom: '8px' }}>
|
||||
{sourceMappingStats.total_sources_mapped}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Sources Mapped</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Research sources intelligently linked to sections
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.coverage_percentage / 100), marginBottom: '8px' }}>
|
||||
{Math.round(sourceMappingStats.coverage_percentage)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Coverage</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Percentage of sections with mapped sources
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(sourceMappingStats.average_relevance_score), marginBottom: '8px' }}>
|
||||
{Math.round(sourceMappingStats.average_relevance_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Relevance</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
How well sources match section content
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{sourceMappingStats.high_confidence_mappings}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>High Confidence</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Mappings with >80% confidence score
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'grounding' && groundingInsights && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Grounding Metadata Insights</h3>
|
||||
|
||||
{/* Confidence Analysis */}
|
||||
{groundingInsights.confidence_analysis && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Confidence Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.confidence_analysis.average_confidence), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.confidence_analysis.average_confidence * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Confidence</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Average confidence score across all sources
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{groundingInsights.confidence_analysis.high_confidence_sources_count}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>High Confidence Sources</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Sources with >80% confidence score
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Authority Analysis */}
|
||||
{groundingInsights.authority_analysis && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Authority Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.authority_analysis.average_authority_score), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.authority_analysis.average_authority_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Avg Authority</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Average authority score of sources
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Top Authority Sources:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.authority_analysis.high_authority_sources.slice(0, 5).map((source, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#e3f2fd',
|
||||
color: '#1976d2',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #bbdefb'
|
||||
}}>
|
||||
{source.title.substring(0, 40)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Relationships */}
|
||||
{groundingInsights.content_relationships && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Content Relationships</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '28px', fontWeight: '700', color: getConfidenceColor(groundingInsights.content_relationships.concept_coverage_score), marginBottom: '8px' }}>
|
||||
{Math.round(groundingInsights.content_relationships.concept_coverage_score * 100)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Concept Coverage</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
How well concepts are covered across sections
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.content_relationships.related_concepts.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>Related Concepts:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.content_relationships.related_concepts.slice(0, 8).map((concept, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#fff3e0',
|
||||
color: '#f57c00',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #ffcc02'
|
||||
}}>
|
||||
{concept}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Intent */}
|
||||
{groundingInsights.search_intent_insights && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Search Intent Analysis</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#1976d2', marginBottom: '8px', textTransform: 'capitalize' }}>
|
||||
{groundingInsights.search_intent_insights.primary_intent}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Primary Intent</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Main user intent identified from search data
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{groundingInsights.search_intent_insights.user_questions.length > 0 && (
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#1f2937' }}>User Questions:</h5>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{groundingInsights.search_intent_insights.user_questions.slice(0, 5).map((question, i) => (
|
||||
<span key={i} style={{
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
border: '1px solid #ce93d8'
|
||||
}}>
|
||||
{question.substring(0, 50)}...
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chipId === 'optimization' && optimizationResults && (
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', color: '#1f2937' }}>Optimization Results</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: getQualityGrade(optimizationResults.overall_quality_score).color, marginBottom: '8px' }}>
|
||||
{optimizationResults.overall_quality_score}/10
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Overall Quality</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
AI-assessed quality score of the outline
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: getQualityGrade(optimizationResults.overall_quality_score).color, marginBottom: '8px' }}>
|
||||
{getQualityGrade(optimizationResults.overall_quality_score).grade}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Quality Grade</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Letter grade based on quality assessment
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '20px', fontWeight: '700', color: '#f57c00', marginBottom: '8px', textTransform: 'capitalize' }}>
|
||||
{optimizationResults.optimization_focus}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Focus Area</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Primary area of optimization focus
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e5e7eb' }}>
|
||||
<div style={{ fontSize: '32px', fontWeight: '700', color: '#4caf50', marginBottom: '8px' }}>
|
||||
{optimizationResults.improvements_made.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#6b7280', fontWeight: '500' }}>Improvements Made</div>
|
||||
<div style={{ fontSize: '12px', color: '#9ca3af', marginTop: '4px' }}>
|
||||
Number of optimizations applied
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{optimizationResults.improvements_made.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#1f2937' }}>Improvements Made:</h4>
|
||||
<div style={{ backgroundColor: '#f8f9fa', borderRadius: '12px', padding: '16px', border: '1px solid #e5e7eb' }}>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{optimizationResults.improvements_made.map((improvement, i) => (
|
||||
<li key={i} style={{ fontSize: '14px', color: '#374151', marginBottom: '8px', lineHeight: '1.5' }}>
|
||||
{improvement}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const availableChips = chips.filter(chip => chip.data);
|
||||
|
||||
if (availableChips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
{availableChips.map(chip => (
|
||||
<button
|
||||
key={chip.id}
|
||||
onClick={() => setActiveModal(chip.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: chip.color,
|
||||
color: chip.textColor,
|
||||
border: 'none',
|
||||
borderRadius: '24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
minWidth: '140px',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.1)';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{chip.icon}</span>
|
||||
<span>{chip.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeModal && renderModal(activeModal)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineIntelligenceChips;
|
||||