Base code

This commit is contained in:
Kunthawat Greethong
2026-01-08 22:39:53 +07:00
parent 697115c61a
commit c35fa52117
2169 changed files with 626670 additions and 0 deletions

BIN
frontend/build/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View 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.*

View 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

File diff suppressed because it is too large Load Diff

66
frontend/package.json Normal file
View 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": "/"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View 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>

View 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"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View 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

Binary file not shown.

View 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
View 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;

View 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;

View 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();

View 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!');

View 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
View 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);
}
);

View 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
View 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();

View 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;

View 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'
);
}
};

View 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
View 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');
}
};

View 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;
};

View 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');
}
};

View 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 =====');
}
};

View 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'
);
}
};

View 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;
}
}
};

View 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;
}
}
};

View 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',
};
}
};

View 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;
}
}
};

View 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;
}

View 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
View 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();

View 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;

View 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;

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

View File

@@ -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;

View File

@@ -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;

View 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;

View 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

View 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;

View 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;

View File

@@ -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;
};

View File

@@ -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}
/>
)}
</>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};
};

View File

@@ -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]);
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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]);
};

View File

@@ -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>;

View 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;

View 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
};

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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;

View 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
};

View 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&#10;Subheading 2&#10;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&#10;Key point 2&#10;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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 &gt;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 &gt;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;

Some files were not shown because too many files have changed in this diff Show More