Allowing AI to generate suggestions for the blog writer

This commit is contained in:
ajaysi
2025-09-20 22:15:17 +05:30
parent 4d153b292d
commit f98d49cea7
22 changed files with 4248 additions and 96 deletions

View File

@@ -31,6 +31,7 @@ from models.blog_models import (
from services.blog_writer.blog_service import BlogWriterService
from .task_manager import task_manager
from .cache_manager import cache_manager
from models.blog_models import MediumBlogGenerateRequest
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
@@ -289,4 +290,40 @@ async def get_outline_cache_entries(limit: int = 20):
return cache_manager.get_recent_outline_cache_entries(limit)
except Exception as e:
logger.error(f"Failed to get outline cache entries: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ---------------------------
# Medium Blog Generation API
# ---------------------------
@router.post("/generate/medium/start")
async def start_medium_generation(request: MediumBlogGenerateRequest):
"""Start medium-length blog generation (≤1000 words) and return a task id."""
try:
# Simple server-side guard
if (request.globalTargetWords or 1000) > 1000:
raise HTTPException(status_code=400, detail="Global target words exceed 1000; use per-section generation")
task_id = task_manager.start_medium_generation_task(request)
return {"task_id": task_id, "status": "started"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to start medium generation: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/generate/medium/status/{task_id}")
async def medium_generation_status(task_id: str):
"""Poll status for medium blog generation task."""
try:
status = task_manager.get_task_status(task_id)
if status is None:
raise HTTPException(status_code=404, detail="Task not found")
return status
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get medium generation status for {task_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -11,7 +11,12 @@ from datetime import datetime
from typing import Any, Dict, List
from loguru import logger
from models.blog_models import BlogResearchRequest, BlogOutlineRequest
from models.blog_models import (
BlogResearchRequest,
BlogOutlineRequest,
MediumBlogGenerateRequest,
MediumBlogGenerateResult,
)
from services.blog_writer.blog_service import BlogWriterService
@@ -106,6 +111,12 @@ class TaskManager:
asyncio.create_task(self._run_outline_generation_task(task_id, request))
return task_id
def start_medium_generation_task(self, request: MediumBlogGenerateRequest) -> str:
"""Start a medium (≤1000 words) full-blog generation task."""
task_id = self.create_task("medium_generation")
asyncio.create_task(self._run_medium_generation_task(task_id, request))
return task_id
async def _run_research_task(self, task_id: str, request: BlogResearchRequest):
"""Background task to run research and update status with progress messages."""
@@ -174,6 +185,45 @@ class TaskManager:
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = str(e)
async def _run_medium_generation_task(self, task_id: str, request: MediumBlogGenerateRequest):
"""Background task to generate a medium blog using a single structured JSON call."""
try:
self.task_storage[task_id]["status"] = "running"
self.task_storage[task_id]["progress_messages"] = []
await self.update_progress(task_id, "📦 Packaging outline and metadata...")
# Basic guard: respect global target words
total_target = int(request.globalTargetWords or 1000)
if total_target > 1000:
raise ValueError("Global target words exceed 1000; medium generation not allowed")
result: MediumBlogGenerateResult = await self.service.generate_medium_blog_with_progress(
request,
task_id,
)
if not result or not getattr(result, "sections", None):
raise ValueError("Empty generation result from model")
# Check if result came from cache
cache_hit = getattr(result, 'cache_hit', False)
if cache_hit:
await self.update_progress(task_id, "⚡ Found cached content - loading instantly!")
else:
await self.update_progress(task_id, "🤖 Generated fresh content with AI...")
await self.update_progress(task_id, "✨ Post-processing and assembling sections...")
# Mark completed
self.task_storage[task_id]["status"] = "completed"
self.task_storage[task_id]["result"] = result.dict()
await self.update_progress(task_id, f"✅ Generated {len(result.sections)} sections successfully.")
except Exception as e:
await self.update_progress(task_id, f"❌ Medium generation failed: {str(e)}")
self.task_storage[task_id]["status"] = "failed"
self.task_storage[task_id]["error"] = str(e)
# Global task manager instance
task_manager = TaskManager()

View File

@@ -215,3 +215,45 @@ class HallucinationCheckResponse(BaseModel):
claims: List[Dict[str, Any]] = []
suggestions: List[Dict[str, Any]] = []
# -----------------------
# Medium Blog Generation
# -----------------------
class MediumSectionOutline(BaseModel):
"""Lightweight outline payload for medium blog generation."""
id: str
heading: str
keyPoints: List[str] = []
subheadings: List[str] = []
keywords: List[str] = []
targetWords: Optional[int] = None
references: List[ResearchSource] = []
class MediumBlogGenerateRequest(BaseModel):
"""Request to generate an entire medium-length blog in one pass."""
title: str
sections: List[MediumSectionOutline]
persona: Optional[PersonaInfo] = None
tone: Optional[str] = None
audience: Optional[str] = None
globalTargetWords: Optional[int] = 1000
researchKeywords: Optional[List[str]] = None # Original research keywords for better caching
class MediumGeneratedSection(BaseModel):
id: str
heading: str
content: str
wordCount: int
sources: Optional[List[ResearchSource]] = None
class MediumBlogGenerateResult(BaseModel):
success: bool = True
title: str
sections: List[MediumGeneratedSection]
model: Optional[str] = None
generation_time_ms: Optional[int] = None
safety_flags: Optional[Dict[str, Any]] = None

View File

@@ -24,11 +24,19 @@ from models.blog_models import (
BlogPublishRequest,
BlogPublishResponse,
BlogOutlineSection,
ResearchSource,
)
from ..research import ResearchService
from ..outline import OutlineService
from ..content.enhanced_content_generator import EnhancedContentGenerator
from services.llm_providers.gemini_provider import gemini_structured_json_response
from services.cache.persistent_content_cache import persistent_content_cache
from models.blog_models import (
MediumBlogGenerateRequest,
MediumBlogGenerateResult,
MediumGeneratedSection,
)
class BlogWriterService:
@@ -258,3 +266,180 @@ class BlogWriterService:
"""Publish content to specified platform."""
# TODO: Move to content module
return BlogPublishResponse(success=True, platform=request.platform, url="https://example.com/post")
async def generate_medium_blog_with_progress(self, req: MediumBlogGenerateRequest, task_id: str) -> MediumBlogGenerateResult:
"""Use Gemini structured JSON to generate a medium-length blog in one call."""
import time
start = time.time()
# Prepare sections data for cache key generation
sections_for_cache = []
for s in req.sections:
sections_for_cache.append({
"id": s.id,
"heading": s.heading,
"keyPoints": getattr(s, "key_points", []) or getattr(s, "keyPoints", []),
"subheadings": getattr(s, "subheadings", []),
"keywords": getattr(s, "keywords", []),
"targetWords": getattr(s, "target_words", None) or getattr(s, "targetWords", None),
})
# Check cache first
cached_result = persistent_content_cache.get_cached_content(
keywords=req.researchKeywords or [],
sections=sections_for_cache,
global_target_words=req.globalTargetWords or 1000,
persona_data=req.persona.dict() if req.persona else None,
tone=req.tone,
audience=req.audience
)
if cached_result:
logger.info(f"Using cached content for keywords: {req.researchKeywords} (saved expensive generation)")
# Add cache hit marker to distinguish from fresh generation
cached_result['generation_time_ms'] = 0 # Mark as cache hit
cached_result['cache_hit'] = True
return MediumBlogGenerateResult(**cached_result)
# Cache miss - proceed with AI generation
logger.info(f"Cache miss - generating new content for keywords: {req.researchKeywords}")
# Build schema expected from the model
schema = {
"type": "object",
"properties": {
"title": {"type": "string"},
"sections": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"heading": {"type": "string"},
"content": {"type": "string"},
"wordCount": {"type": "number"},
"sources": {
"type": "array",
"items": {
"type": "object",
"properties": {"title": {"type": "string"}, "url": {"type": "string"}},
},
},
},
},
},
},
}
# Compose prompt
def section_block(s):
return {
"id": s.id,
"heading": s.heading,
"outline": {
"keyPoints": getattr(s, "key_points", []) or getattr(s, "keyPoints", []),
"subheadings": getattr(s, "subheadings", []),
"keywords": getattr(s, "keywords", []),
"targetWords": getattr(s, "target_words", None) or getattr(s, "targetWords", None),
"references": [
{"title": r.title, "url": r.url} for r in getattr(s, "references", [])
],
},
}
payload = {
"title": req.title,
"globalTargetWords": req.globalTargetWords or 1000,
"persona": req.persona.dict() if req.persona else None,
"tone": req.tone,
"audience": req.audience,
"sections": [section_block(s) for s in req.sections],
}
system = (
"You are a professional blog writer. Generate high-quality content for each section based on the provided outline. "
"Write engaging, informative content that follows the section's key points and target word count. "
"Use a professional tone and ensure the content flows naturally. "
"Format content with proper paragraph breaks using double line breaks (\\n\\n) between paragraphs. "
"Structure content with clear paragraphs - aim for 2-4 sentences per paragraph. "
"Return ONLY valid JSON with no markdown formatting or explanations."
)
import json
prompt = (
f"Write blog content for the following sections. Each section should be {req.globalTargetWords or 1000} words total, distributed across all sections.\n\n"
f"Blog Title: {req.title}\n\n"
"For each section, write engaging content that:\n"
"- Follows the key points provided\n"
"- Uses the suggested keywords naturally\n"
"- Meets the target word count\n"
"- Maintains professional tone\n"
"- References the provided sources when relevant\n"
"- Breaks content into clear paragraphs (2-4 sentences each)\n"
"- Uses double line breaks (\\n\\n) between paragraphs for proper formatting\n"
"- Starts with an engaging opening paragraph\n"
"- Ends with a strong concluding paragraph\n\n"
"IMPORTANT: Format the 'content' field with proper paragraph breaks using \\n\\n between paragraphs.\n\n"
"Return a JSON object with 'title' and 'sections' array. Each section should have 'id', 'heading', 'content', and 'wordCount'.\n\n"
f"Sections to write:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
)
ai_resp = gemini_structured_json_response(
prompt=prompt,
schema=schema,
temperature=0.2,
max_tokens=8192,
system_prompt=system,
)
# Check for errors in AI response
if not ai_resp or ai_resp.get("error"):
error_msg = ai_resp.get("error", "Empty generation result from model") if ai_resp else "No response from model"
logger.error(f"AI generation failed: {error_msg}")
raise Exception(f"AI generation failed: {error_msg}")
# Normalize output
title = ai_resp.get("title") or req.title
out_sections = []
for s in ai_resp.get("sections", []) or []:
out_sections.append(
MediumGeneratedSection(
id=str(s.get("id")),
heading=s.get("heading") or "",
content=s.get("content") or "",
wordCount=int(s.get("wordCount") or 0),
sources=[
# map to ResearchSource shape if possible; keep minimal
ResearchSource(title=src.get("title", ""), url=src.get("url", ""))
for src in (s.get("sources") or [])
] or None,
)
)
duration_ms = int((time.time() - start) * 1000)
result = MediumBlogGenerateResult(
success=True,
title=title,
sections=out_sections,
model="gemini-2.5-flash",
generation_time_ms=duration_ms,
safety_flags=None,
)
# Cache the result for future use
try:
persistent_content_cache.cache_content(
keywords=req.researchKeywords or [],
sections=sections_for_cache,
global_target_words=req.globalTargetWords or 1000,
persona_data=req.persona.dict() if req.persona else None,
tone=req.tone or "professional",
audience=req.audience or "general",
result=result.dict()
)
logger.info(f"Cached content result for keywords: {req.researchKeywords}")
except Exception as cache_error:
logger.warning(f"Failed to cache content result: {cache_error}")
# Don't fail the entire operation if caching fails
return result

View File

@@ -0,0 +1,363 @@
"""
Persistent Content Cache Service
Provides database-backed caching for blog content generation results to survive server restarts
and provide better cache management across multiple instances.
"""
import hashlib
import json
import sqlite3
from typing import Dict, Any, Optional, List
from datetime import datetime, timedelta
from pathlib import Path
from loguru import logger
class PersistentContentCache:
"""Database-backed cache for blog content generation results with exact parameter matching."""
def __init__(self, db_path: str = "content_cache.db", max_cache_size: int = 300, cache_ttl_hours: int = 72):
"""
Initialize the persistent content cache.
Args:
db_path: Path to SQLite database file
max_cache_size: Maximum number of cached entries
cache_ttl_hours: Time-to-live for cache entries in hours (longer than research cache since content is expensive)
"""
self.db_path = db_path
self.max_cache_size = max_cache_size
self.cache_ttl = timedelta(hours=cache_ttl_hours)
# Ensure database directory exists
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# Initialize database
self._init_database()
def _init_database(self):
"""Initialize the SQLite database with required tables."""
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS content_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cache_key TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
sections_hash TEXT NOT NULL,
global_target_words INTEGER NOT NULL,
persona_data TEXT,
tone TEXT,
audience TEXT,
result_data TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
access_count INTEGER DEFAULT 0,
last_accessed TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Create indexes for better performance
conn.execute("CREATE INDEX IF NOT EXISTS idx_content_cache_key ON content_cache(cache_key)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_content_expires_at ON content_cache(expires_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_content_created_at ON content_cache(created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_content_title ON content_cache(title)")
conn.commit()
def _generate_sections_hash(self, sections: List[Dict[str, Any]]) -> str:
"""
Generate a hash for sections based on their structure and content.
Args:
sections: List of section dictionaries with outline information
Returns:
MD5 hash of the normalized sections
"""
# Normalize sections for consistent hashing
normalized_sections = []
for section in sections:
normalized_section = {
'id': section.get('id', ''),
'heading': section.get('heading', '').lower().strip(),
'keyPoints': sorted([str(kp).lower().strip() for kp in section.get('keyPoints', [])]),
'keywords': sorted([str(kw).lower().strip() for kw in section.get('keywords', [])]),
'subheadings': sorted([str(sh).lower().strip() for sh in section.get('subheadings', [])]),
'targetWords': section.get('targetWords', 0),
# Don't include references in hash as they might vary but content should remain similar
}
normalized_sections.append(normalized_section)
# Sort sections by id for consistent ordering
normalized_sections.sort(key=lambda x: x['id'])
# Generate hash
sections_str = json.dumps(normalized_sections, sort_keys=True)
return hashlib.md5(sections_str.encode('utf-8')).hexdigest()
def _generate_cache_key(self, keywords: List[str], sections: List[Dict[str, Any]],
global_target_words: int, persona_data: Dict = None,
tone: str = None, audience: str = None) -> str:
"""
Generate a cache key based on exact parameter match.
Args:
keywords: Original research keywords (primary cache key)
sections: List of section dictionaries with outline information
global_target_words: Target word count for entire blog
persona_data: Persona information
tone: Content tone
audience: Target audience
Returns:
MD5 hash of the normalized parameters
"""
# Normalize parameters
normalized_keywords = sorted([kw.lower().strip() for kw in (keywords or [])])
sections_hash = self._generate_sections_hash(sections)
normalized_tone = tone.lower().strip() if tone else "professional"
normalized_audience = audience.lower().strip() if audience else "general"
# Normalize persona data
normalized_persona = ""
if persona_data:
# Sort persona keys and values for consistent hashing
persona_str = json.dumps(persona_data, sort_keys=True, default=str)
normalized_persona = persona_str.lower()
# Create a consistent string representation
cache_string = f"{normalized_keywords}|{sections_hash}|{global_target_words}|{normalized_tone}|{normalized_audience}|{normalized_persona}"
# Generate MD5 hash
return hashlib.md5(cache_string.encode('utf-8')).hexdigest()
def _cleanup_expired_entries(self):
"""Remove expired cache entries from database."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute(
"DELETE FROM content_cache WHERE expires_at < ?",
(datetime.now().isoformat(),)
)
deleted_count = cursor.rowcount
if deleted_count > 0:
logger.debug(f"Removed {deleted_count} expired content cache entries")
conn.commit()
def _evict_oldest_entries(self, num_to_evict: int):
"""Evict the oldest cache entries when cache is full."""
with sqlite3.connect(self.db_path) as conn:
# Get oldest entries by creation time
cursor = conn.execute("""
SELECT id FROM content_cache
ORDER BY created_at ASC
LIMIT ?
""", (num_to_evict,))
old_ids = [row[0] for row in cursor.fetchall()]
if old_ids:
placeholders = ','.join(['?' for _ in old_ids])
conn.execute(f"DELETE FROM content_cache WHERE id IN ({placeholders})", old_ids)
logger.debug(f"Evicted {len(old_ids)} oldest content cache entries")
conn.commit()
def get_cached_content(self, keywords: List[str], sections: List[Dict[str, Any]],
global_target_words: int, persona_data: Dict = None,
tone: str = None, audience: str = None) -> Optional[Dict[str, Any]]:
"""
Get cached content result for exact parameter match.
Args:
keywords: Original research keywords (primary cache key)
sections: List of section dictionaries with outline information
global_target_words: Target word count for entire blog
persona_data: Persona information
tone: Content tone
audience: Target audience
Returns:
Cached content result if found and valid, None otherwise
"""
cache_key = self._generate_cache_key(keywords, sections, global_target_words, persona_data, tone, audience)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute("""
SELECT result_data, expires_at FROM content_cache
WHERE cache_key = ? AND expires_at > ?
""", (cache_key, datetime.now().isoformat()))
row = cursor.fetchone()
if row is None:
logger.debug(f"Content cache miss for keywords: {keywords}, sections: {len(sections)}")
return None
# Update access statistics
conn.execute("""
UPDATE content_cache
SET access_count = access_count + 1, last_accessed = CURRENT_TIMESTAMP
WHERE cache_key = ?
""", (cache_key,))
conn.commit()
try:
result_data = json.loads(row[0])
logger.info(f"Content cache hit for keywords: {keywords} (saved expensive generation)")
return result_data
except json.JSONDecodeError:
logger.error(f"Invalid JSON in content cache for keywords: {keywords}")
# Remove invalid entry
conn.execute("DELETE FROM content_cache WHERE cache_key = ?", (cache_key,))
conn.commit()
return None
def cache_content(self, keywords: List[str], sections: List[Dict[str, Any]],
global_target_words: int, persona_data: Dict, tone: str,
audience: str, result: Dict[str, Any]):
"""
Cache a content generation result.
Args:
keywords: Original research keywords (primary cache key)
sections: List of section dictionaries with outline information
global_target_words: Target word count for entire blog
persona_data: Persona information
tone: Content tone
audience: Target audience
result: Content result to cache
"""
cache_key = self._generate_cache_key(keywords, sections, global_target_words, persona_data, tone, audience)
sections_hash = self._generate_sections_hash(sections)
# Cleanup expired entries first
self._cleanup_expired_entries()
# Check if cache is full and evict if necessary
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute("SELECT COUNT(*) FROM content_cache")
current_count = cursor.fetchone()[0]
if current_count >= self.max_cache_size:
num_to_evict = current_count - self.max_cache_size + 1
self._evict_oldest_entries(num_to_evict)
# Store the result
expires_at = datetime.now() + self.cache_ttl
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
INSERT OR REPLACE INTO content_cache
(cache_key, title, sections_hash, global_target_words, persona_data, tone, audience, result_data, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
cache_key,
json.dumps(keywords), # Store keywords as JSON
sections_hash,
global_target_words,
json.dumps(persona_data) if persona_data else "",
tone or "",
audience or "",
json.dumps(result),
expires_at.isoformat()
))
conn.commit()
logger.info(f"Cached content result for keywords: {keywords}, {len(sections)} sections")
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics."""
self._cleanup_expired_entries()
with sqlite3.connect(self.db_path) as conn:
# Get basic stats
cursor = conn.execute("SELECT COUNT(*) FROM content_cache")
total_entries = cursor.fetchone()[0]
cursor = conn.execute("SELECT COUNT(*) FROM content_cache WHERE expires_at > ?", (datetime.now().isoformat(),))
valid_entries = cursor.fetchone()[0]
# Get most accessed entries
cursor = conn.execute("""
SELECT title, global_target_words, access_count, created_at
FROM content_cache
ORDER BY access_count DESC
LIMIT 10
""")
top_entries = [
{
'title': row[0],
'global_target_words': row[1],
'access_count': row[2],
'created_at': row[3]
}
for row in cursor.fetchall()
]
# Get database size
cursor = conn.execute("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()")
db_size_bytes = cursor.fetchone()[0]
db_size_mb = db_size_bytes / (1024 * 1024)
return {
'total_entries': total_entries,
'valid_entries': valid_entries,
'expired_entries': total_entries - valid_entries,
'max_size': self.max_cache_size,
'ttl_hours': self.cache_ttl.total_seconds() / 3600,
'database_size_mb': round(db_size_mb, 2),
'top_accessed_entries': top_entries
}
def clear_cache(self):
"""Clear all cached entries."""
with sqlite3.connect(self.db_path) as conn:
conn.execute("DELETE FROM content_cache")
conn.commit()
logger.info("Content cache cleared")
def get_cache_entries(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Get recent cache entries for debugging."""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute("""
SELECT title, global_target_words, tone, audience, created_at, expires_at, access_count
FROM content_cache
ORDER BY created_at DESC
LIMIT ?
""", (limit,))
return [
{
'title': row[0],
'global_target_words': row[1],
'tone': row[2],
'audience': row[3],
'created_at': row[4],
'expires_at': row[5],
'access_count': row[6]
}
for row in cursor.fetchall()
]
def invalidate_cache_for_title(self, title: str):
"""
Invalidate all cache entries for specific title.
Useful when outline is updated.
Args:
title: Title to invalidate cache for
"""
normalized_title = title.lower().strip()
with sqlite3.connect(self.db_path) as conn:
cursor = conn.execute("DELETE FROM content_cache WHERE LOWER(title) = ?", (normalized_title,))
deleted_count = cursor.rowcount
conn.commit()
if deleted_count > 0:
logger.info(f"Invalidated {deleted_count} content cache entries for title: {title}")
# Global persistent cache instance
persistent_content_cache = PersistentContentCache()

View File

@@ -407,11 +407,50 @@ def gemini_structured_json_response(prompt, schema, temperature=0.7, top_p=0.9,
logger.info("No parsed content, trying to parse text response")
try:
import json
parsed_text = json.loads(response.text)
import re
# Clean the text response to fix common JSON issues
cleaned_text = response.text.strip()
# Remove any markdown code blocks if present
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:]
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3]
cleaned_text = cleaned_text.strip()
# Try to find JSON content between curly braces
json_match = re.search(r'\{.*\}', cleaned_text, re.DOTALL)
if json_match:
cleaned_text = json_match.group(0)
parsed_text = json.loads(cleaned_text)
logger.info("Successfully parsed text as JSON")
return parsed_text
except json.JSONDecodeError as e:
logger.error(f"Failed to parse text as JSON: {e}")
logger.debug(f"Problematic text (first 500 chars): {response.text[:500]}")
# Try to extract and fix JSON manually
try:
import re
# Look for the main JSON object
json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
matches = re.findall(json_pattern, response.text, re.DOTALL)
if matches:
# Try the largest match (likely the main JSON)
largest_match = max(matches, key=len)
# Basic cleanup of common issues
fixed_json = largest_match.replace('\n', ' ').replace('\r', ' ')
# Remove any trailing commas before closing braces
fixed_json = re.sub(r',\s*}', '}', fixed_json)
fixed_json = re.sub(r',\s*]', ']', fixed_json)
parsed_text = json.loads(fixed_json)
logger.info("Successfully parsed cleaned JSON")
return parsed_text
except Exception as fix_error:
logger.error(f"Failed to fix JSON manually: {fix_error}")
# Check candidates for content (fallback for edge cases)
if hasattr(response, 'candidates') and response.candidates:

View File

@@ -1,8 +1,8 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { CopilotSidebar } from '@copilotkit/react-ui';
import '@copilotkit/react-ui/styles.css';
import { blogWriterApi } from '../../services/blogWriterApi';
import { useOutlinePolling } from '../../hooks/usePolling';
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling } from '../../hooks/usePolling';
import { useClaimFixer } from '../../hooks/useClaimFixer';
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
@@ -20,11 +20,12 @@ import { EnhancedOutlineActions } from './EnhancedOutlineActions';
import HallucinationChecker from './HallucinationChecker';
import Publisher from './Publisher';
import OutlineGenerator from './OutlineGenerator';
import SectionGenerator from './SectionGenerator';
import OutlineRefiner from './OutlineRefiner';
import SEOProcessor from './SEOProcessor';
import BlogWriterLanding from './BlogWriterLanding';
import ResearchProgressModal from './ResearchProgressModal';
import { OutlineProgressModal } from './OutlineProgressModal';
import OutlineFeedbackForm from './OutlineFeedbackForm';
import { BlogEditor } from './WYSIWYG';
export const BlogWriter: React.FC = () => {
// Use custom hook for all state management
@@ -45,6 +46,7 @@ export const BlogWriter: React.FC = () => {
researchCoverage,
researchTitles,
aiGeneratedTitles,
outlineConfirmed,
setOutline,
setTitleOptions,
setSections,
@@ -55,10 +57,12 @@ export const BlogWriter: React.FC = () => {
handleResearchComplete,
handleOutlineComplete,
handleOutlineError,
handleSectionGenerated,
handleContinuityRefresh,
handleTitleSelect,
handleCustomTitle
handleCustomTitle,
handleOutlineConfirmed,
handleOutlineRefined,
handleContentUpdate,
handleContentSave
} = useBlogWriterState();
// Custom hooks for complex functionality
@@ -68,13 +72,16 @@ export const BlogWriter: React.FC = () => {
setSections
);
const { convertMarkdownToHTML, getTotalWords, getOutlineStats } = useMarkdownProcessor(
const { convertMarkdownToHTML } = useMarkdownProcessor(
outline,
sections
);
// Get suggestions
const suggestions = useSuggestions(research, outline);
// Research polling hook (for context awareness)
const researchPolling = useResearchPolling({
onComplete: handleResearchComplete,
onError: (error) => console.error('Research polling error:', error)
});
// Outline polling hook
const outlinePolling = useOutlinePolling({
@@ -82,22 +89,90 @@ export const BlogWriter: React.FC = () => {
onError: handleOutlineError
});
// 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 || '';
});
setSections(newSections);
}
} catch (e) {
console.error('Failed to apply medium generation result:', e);
}
},
onError: (err) => console.error('Medium generation failed:', err)
});
// Get context-aware suggestions based on current task status
const suggestions = useSuggestions(
research,
outline,
outlineConfirmed,
{ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
{ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
{ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus }
);
// Add minimum display time for modal
const [showModal, setShowModal] = useState(false);
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
useEffect(() => {
if ((mediumPolling.isPolling || isMediumGenerationStarting) && !showModal) {
setShowModal(true);
setModalStartTime(Date.now());
} else if (!mediumPolling.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, isMediumGenerationStarting, showModal, modalStartTime]);
// 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);
};
// Debug medium polling state
console.log('Medium polling state:', {
isPolling: mediumPolling.isPolling,
status: mediumPolling.currentStatus,
progressCount: mediumPolling.progressMessages.length
});
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* Outline Progress Modal */}
<ResearchProgressModal
open={Boolean(outlineTaskId && (outlinePolling.isPolling || outlinePolling.currentStatus === 'pending' || outlinePolling.currentStatus === 'running'))}
title="Outline generation in progress"
status={outlinePolling.currentStatus}
messages={outlinePolling.progressMessages}
error={outlinePolling.error}
onClose={() => { /* informational while processing */ }}
/>
{/* Extracted Components */}
<KeywordInputForm onResearchComplete={handleResearchComplete} />
<KeywordInputForm
onResearchComplete={handleResearchComplete}
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
/>
<CustomOutlineForm onOutlineCreated={setOutline} />
<ResearchAction onResearchComplete={handleResearchComplete} />
<ResearchDataActions
@@ -109,6 +184,14 @@ export const BlogWriter: React.FC = () => {
outline={outline}
onOutlineUpdated={setOutline}
/>
<OutlineFeedbackForm
outline={outline}
research={research!}
onOutlineConfirmed={handleOutlineConfirmed}
onOutlineRefined={handleOutlineRefined}
onMediumGenerationStarted={handleMediumGenerationStarted}
onMediumGenerationTriggered={handleMediumGenerationTriggered}
/>
{/* New extracted functionality components */}
<OutlineGenerator
@@ -116,13 +199,6 @@ export const BlogWriter: React.FC = () => {
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
/>
<SectionGenerator
outline={outline}
research={research}
genMode={genMode}
onSectionGenerated={handleSectionGenerated}
onContinuityRefresh={handleContinuityRefresh}
/>
<OutlineRefiner
outline={outline}
onOutlineUpdated={setOutline}
@@ -161,57 +237,75 @@ export const BlogWriter: React.FC = () => {
{research && outline.length === 0 && <ResearchResults research={research} />}
{outline.length > 0 && (
<div>
{/* Enhanced Title Selection */}
<EnhancedTitleSelector
{outlineConfirmed ? (
/* WYSIWYG Editor - Show when outline is confirmed */
<BlogEditor
outline={outline}
research={research}
initialTitle={selectedTitle}
titleOptions={titleOptions}
selectedTitle={selectedTitle}
sections={outline}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
sections={sections}
onContentUpdate={handleContentUpdate}
onSave={handleContentSave}
/>
) : (
/* Outline Editor - Show when outline is not confirmed */
<>
{/* Enhanced Title Selection */}
<EnhancedTitleSelector
titleOptions={titleOptions}
selectedTitle={selectedTitle}
sections={outline}
researchTitles={researchTitles}
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
/>
{/* Enhanced Outline Editor */}
<EnhancedOutlineEditor
outline={outline}
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
/>
{/* Enhanced Outline Editor */}
<EnhancedOutlineEditor
outline={outline}
research={research}
sourceMappingStats={sourceMappingStats}
groundingInsights={groundingInsights}
optimizationResults={optimizationResults}
researchCoverage={researchCoverage}
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
/>
{/* Draft/Polished Mode Toggle */}
<div style={{ margin: '12px 0' }}>
<label style={{ marginRight: 8 }}>Generation mode:</label>
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
<option value="draft">Draft (faster, lower cost)</option>
<option value="polished">Polished (higher quality)</option>
</select>
</div>
{outline.map(s => (
<div key={s.id} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h4 style={{ margin: 0 }}>{s.heading}</h4>
{/* Continuity badge */}
{sections[s.id] && (
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
)}
{/* Draft/Polished Mode Toggle */}
<div style={{ margin: '12px 0' }}>
<label style={{ marginRight: 8 }}>Generation mode:</label>
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
<option value="draft">Draft (faster, lower cost)</option>
<option value="polished">Polished (higher quality)</option>
</select>
</div>
{sections[s.id] ? (
<>
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
<SEOMiniPanel analysis={seoAnalysis} />
</>
) : (
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
)}
</div>
))}
{outline.map(s => (
<div key={s.id} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<h4 style={{ margin: 0 }}>{s.heading}</h4>
{/* Continuity badge */}
{sections[s.id] && (
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
)}
</div>
{sections[s.id] ? (
<>
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
<SEOMiniPanel analysis={seoAnalysis} />
</>
) : (
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
)}
</div>
))}
</>
)}
</div>
)}
</div>
@@ -231,6 +325,7 @@ export const BlogWriter: React.FC = () => {
// Get current state information
const hasResearch = research !== null;
const hasOutline = outline.length > 0;
const isOutlineConfirmed = outlineConfirmed;
const researchInfo = hasResearch ? {
sources: research.sources?.length || 0,
queries: research.search_queries?.length || 0,
@@ -239,6 +334,14 @@ export const BlogWriter: React.FC = () => {
searchIntent: research.keyword_analysis?.search_intent || 'informational'
} : null;
const outlineContext = hasOutline ? `
OUTLINE DETAILS:
- Total sections: ${outline.length}
- Section headings: ${outline.map(s => s.heading).join(', ')}
- Total target words: ${outline.reduce((sum, s) => sum + (s.target_words || 0), 0)}
- Section breakdown: ${outline.map(s => `${s.heading} (${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.
@@ -252,7 +355,8 @@ ${hasResearch && researchInfo ? `
- Search intent: ${researchInfo.searchIntent}
` : '❌ No research completed yet'}
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created` : '❌ No outline generated yet'}
${hasOutline ? `✅ 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
@@ -261,9 +365,12 @@ Available tools:
- 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()
- refineOutline(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
- 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
@@ -282,20 +389,48 @@ Available tools:
- 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
- When user asks to generate content, call generateSection or generateAllSections
- 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 clicks "Confirm & Generate Content", ONLY call confirmOutlineAndGenerateContent() - DO NOT automatically generate content
- 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]"
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 → Content → SEO → Publish
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → 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');
}}
/>
{/* Outline Progress Modal */}
{/* Outline modal */}
<OutlineProgressModal
isVisible={outlinePolling.isPolling}
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}
/>
{/* Medium generation modal */}
<OutlineProgressModal
isVisible={showModal}
status={mediumPolling.currentStatus}
progressMessages={mediumPolling.progressMessages.map(m => m.message)}
latestMessage={mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''}
error={mediumPolling.error}
titleOverride={'📝 Generating Your Blog Content'}
/>
</div>
);
};

View File

@@ -9,6 +9,7 @@ const useCopilotActionTyped = useCopilotAction as any;
interface KeywordInputFormProps {
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
onResearchComplete?: (researchData: BlogResearchResponse) => void;
onTaskStart?: (taskId: string) => void;
}
// Separate component to manage form state
@@ -140,7 +141,7 @@ const ResearchForm: React.FC<{
);
};
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete }) => {
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
// Keyword input action with Human-in-the-Loop
@@ -214,9 +215,13 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
word_count_target: parseInt(blogLength)
};
// Store the blog length in localStorage for later use
localStorage.setItem('blog_length_target', blogLength);
// Start async research
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
onTaskStart?.(task_id); // Notify parent component to start polling
return {
success: true,

View File

@@ -0,0 +1,498 @@
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;
}
// 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,
onOutlineConfirmed,
onOutlineRefined,
onMediumGenerationStarted,
onMediumGenerationTriggered
}) => {
// 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 {
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) {
// 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);
// Return message so the copilot shows feedback; UI will display modal via BlogWriter
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.'
};
}
}
});
return null; // This component only provides the copilot actions
};
export default OutlineFeedbackForm;

View File

@@ -0,0 +1,290 @@
import React from 'react';
interface OutlineProgressModalProps {
isVisible: boolean;
status: string;
progressMessages: string[];
latestMessage: string;
error: string | null;
titleOverride?: string;
}
export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
isVisible,
status,
progressMessages,
latestMessage,
error,
titleOverride
}) => {
if (!isVisible) return null;
const getUserFriendlyMessage = (message: string): string => {
// Map technical backend messages to user-friendly ones
if (message.includes('Starting outline generation')) {
return '🧩 Starting to create your blog outline...';
}
if (message.includes('Analyzing research data and building content strategy')) {
return '📊 Analyzing your research data to build the perfect content strategy...';
}
if (message.includes('Generating AI-powered outline with research insights')) {
return '🤖 Creating an intelligent outline using AI and your research insights...';
}
if (message.includes('Making AI request to generate structured outline')) {
return '🔄 Generating your structured blog outline...';
}
if (message.includes('Calling Gemini API for outline generation')) {
return '🤖 AI is crafting your personalized blog structure...';
}
if (message.includes('Processing outline structure and validating sections')) {
return '📝 Processing and validating your outline sections...';
}
if (message.includes('Running parallel processing for maximum speed')) {
return '⚡ Optimizing processing speed for faster results...';
}
if (message.includes('Applying intelligent source-to-section mapping')) {
return '🔗 Intelligently matching your research sources to outline sections...';
}
if (message.includes('Extracting grounding metadata insights')) {
return '🧠 Extracting valuable insights from your research data...';
}
if (message.includes('Enhancing sections with grounding insights')) {
return '✨ Enhancing your outline sections with research-backed insights...';
}
if (message.includes('Optimizing outline for better flow and engagement')) {
return '🎯 Optimizing your outline for maximum reader engagement...';
}
if (message.includes('Rebalancing word count distribution')) {
return '⚖️ Balancing content distribution across sections...';
}
if (message.includes('Outline generation and optimization completed successfully')) {
return '✅ Your blog outline has been successfully created and optimized!';
}
if (message.includes('Outline generated successfully')) {
return '🎉 Success! Your personalized blog outline is ready!';
}
// Return the original message if no mapping found
return message;
};
const getProgressPercentage = (): number => {
if (status === 'complete') return 100;
if (status === 'error') return 0;
// Estimate progress based on common message patterns
const messageCount = progressMessages.length;
if (messageCount === 0) return 0;
if (messageCount >= 10) return 90;
return Math.min(messageCount * 10, 90);
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '0',
maxWidth: '600px',
width: '90%',
maxHeight: '80vh',
overflow: 'hidden',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e5e7eb'
}}>
{/* Header with background image */}
<div style={{
backgroundImage: 'url(/blog-writer-bg.png)',
backgroundSize: 'cover',
backgroundPosition: 'center',
padding: '32px',
color: 'white',
textAlign: 'center',
position: 'relative'
}}>
{/* Dark overlay */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
borderRadius: '16px 16px 0 0'
}} />
<div style={{ position: 'relative', zIndex: 1 }}>
<h2 style={{
margin: '0 0 16px 0',
fontSize: '24px',
fontWeight: '700',
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)'
}}>
{titleOverride || (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
</h2>
{/* Progress Bar */}
<div style={{
backgroundColor: 'rgba(255, 255, 255, 0.2)',
borderRadius: '12px',
height: '8px',
overflow: 'hidden',
marginBottom: '16px'
}}>
<div style={{
backgroundColor: status === 'error' ? '#ef4444' : '#10b981',
height: '100%',
width: `${getProgressPercentage()}%`,
transition: 'width 0.3s ease',
borderRadius: '12px'
}} />
</div>
<p style={{
margin: 0,
fontSize: '16px',
opacity: 0.9,
textShadow: '1px 1px 2px rgba(0, 0, 0, 0.3)'
}}>
{titleOverride
? (status === 'complete'
? 'Your AI-generated blog content is ready!'
: status === 'error'
? 'Something went wrong during generation'
: 'AI is generating your blog content...')
: (status === 'complete'
? 'Your AI-powered blog outline is ready to use!'
: status === 'error'
? 'Something went wrong during outline generation'
: 'AI is analyzing your research and creating the perfect blog structure...')}
</p>
</div>
</div>
{/* Content */}
<div style={{ padding: '24px' }}>
{error ? (
<div style={{
backgroundColor: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '8px',
padding: '16px',
color: '#dc2626'
}}>
<strong>Error:</strong> {error}
</div>
) : (
<>
{/* Current Status */}
<div style={{
backgroundColor: '#f0f9ff',
border: '1px solid #bae6fd',
borderRadius: '8px',
padding: '16px',
marginBottom: '20px'
}}>
<div style={{
fontSize: '14px',
fontWeight: '600',
color: '#0369a1',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: status === 'complete' ? '#10b981' : '#3b82f6',
animation: status === 'executing' ? 'pulse 2s infinite' : 'none'
}} />
Current Status
</div>
<div style={{
fontSize: '15px',
color: '#1e40af',
lineHeight: '1.5'
}}>
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing to generate your outline...'}
</div>
</div>
{/* Progress Messages */}
{progressMessages.length > 0 && (
<div>
<h4 style={{
margin: '0 0 12px 0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
}}>
Progress Timeline
</h4>
<div style={{
maxHeight: '200px',
overflowY: 'auto',
backgroundColor: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '12px'
}}>
{progressMessages.slice().reverse().slice(0, 8).map((message, index) => (
<div key={index} style={{
fontSize: '13px',
color: '#6b7280',
marginBottom: index < Math.min(progressMessages.length - 1, 7) ? '8px' : '0',
paddingLeft: '20px',
position: 'relative',
lineHeight: '1.4'
}}>
<span style={{
position: 'absolute',
left: '0',
top: '2px',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: index === 0 ? '#10b981' : '#d1d5db'
}} />
{getUserFriendlyMessage(message)}
</div>
))}
</div>
</div>
)}
</>
)}
</div>
{/* CSS for pulse animation */}
<style>
{`
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
`}
</style>
</div>
</div>
);
};

View File

@@ -0,0 +1,108 @@
import React from 'react';
interface StyledSuggestion {
title: string;
message: string;
priority?: 'high' | 'normal';
}
interface StyledSuggestionsProps {
suggestions: StyledSuggestion[];
onSuggestionClick: (suggestion: StyledSuggestion) => void;
}
export const StyledSuggestions: React.FC<StyledSuggestionsProps> = ({
suggestions,
onSuggestionClick
}) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{suggestions.map((suggestion, index) => {
const isHighPriority = suggestion.priority === 'high';
return (
<button
key={index}
onClick={() => onSuggestionClick(suggestion)}
style={{
padding: isHighPriority ? '16px 20px' : '12px 16px',
backgroundColor: isHighPriority ? '#1976d2' : '#f5f5f5',
color: isHighPriority ? 'white' : '#333',
border: isHighPriority ? '2px solid #1976d2' : '1px solid #ddd',
borderRadius: '8px',
fontSize: isHighPriority ? '16px' : '14px',
fontWeight: isHighPriority ? '600' : '500',
textAlign: 'left',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: isHighPriority ? '0 2px 8px rgba(25, 118, 210, 0.2)' : '0 1px 3px rgba(0, 0, 0, 0.1)',
position: 'relative',
overflow: 'hidden'
}}
onMouseEnter={(e) => {
if (isHighPriority) {
e.currentTarget.style.backgroundColor = '#1565c0';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(25, 118, 210, 0.3)';
} else {
e.currentTarget.style.backgroundColor = '#e8e8e8';
e.currentTarget.style.borderColor = '#1976d2';
}
}}
onMouseLeave={(e) => {
if (isHighPriority) {
e.currentTarget.style.backgroundColor = '#1976d2';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(25, 118, 210, 0.2)';
} else {
e.currentTarget.style.backgroundColor = '#f5f5f5';
e.currentTarget.style.borderColor = '#ddd';
}
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: isHighPriority ? '4px' : '2px'
}}>
<span style={{
fontSize: isHighPriority ? '18px' : '16px',
filter: isHighPriority ? 'brightness(0) invert(1)' : 'none'
}}>
{suggestion.title.split(' ')[0]}
</span>
<span style={{
fontSize: isHighPriority ? '16px' : '14px',
fontWeight: isHighPriority ? '600' : '500'
}}>
{suggestion.title.split(' ').slice(1).join(' ')}
</span>
</div>
<div style={{
fontSize: isHighPriority ? '14px' : '12px',
opacity: isHighPriority ? '0.9' : '0.7',
lineHeight: '1.4'
}}>
{suggestion.message}
</div>
{isHighPriority && (
<div style={{
position: 'absolute',
top: '0',
right: '0',
width: '0',
height: '0',
borderLeft: '20px solid transparent',
borderTop: '20px solid rgba(255, 255, 255, 0.1)',
pointerEvents: 'none'
}} />
)}
</button>
);
})}
</div>
);
};
export default StyledSuggestions;

View File

@@ -4,19 +4,71 @@ import { BlogOutlineSection, BlogResearchResponse } from '../../services/blogWri
interface SuggestionsGeneratorProps {
research: BlogResearchResponse | null;
outline: BlogOutlineSection[];
outlineConfirmed?: boolean;
researchPolling?: { isPolling: boolean; currentStatus: string };
outlinePolling?: { isPolling: boolean; currentStatus: string };
mediumPolling?: { isPolling: boolean; currentStatus: string };
}
export const useSuggestions = (research: BlogResearchResponse | null, outline: BlogOutlineSection[]) => {
export const useSuggestions = (
research: BlogResearchResponse | null,
outline: BlogOutlineSection[],
outlineConfirmed: boolean = false,
researchPolling?: { isPolling: boolean; currentStatus: string },
outlinePolling?: { isPolling: boolean; currentStatus: string },
mediumPolling?: { isPolling: boolean; currentStatus: string }
) => {
return useMemo(() => {
const items = [] as { title: string; message: string }[];
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
// Check if any background tasks are currently running
const isResearchRunning = researchPolling?.isPolling && researchPolling?.currentStatus !== 'completed';
const isOutlineRunning = outlinePolling?.isPolling && outlinePolling?.currentStatus !== 'completed';
const isMediumGenerationRunning = mediumPolling?.isPolling && mediumPolling?.currentStatus !== 'completed';
// If research is running, show status instead of other suggestions
if (isResearchRunning) {
items.push({
title: '⏳ Research in Progress...',
message: `Research is currently running (${researchPolling?.currentStatus}). Please wait for completion.`,
priority: 'high'
});
return items;
}
// If outline generation is running, show status
if (isOutlineRunning) {
items.push({
title: '⏳ Outline Generation in Progress...',
message: `Outline is being generated (${outlinePolling?.currentStatus}). Please wait for completion.`,
priority: 'high'
});
return items;
}
// If medium generation is running, show status
if (isMediumGenerationRunning) {
items.push({
title: '⏳ Content Generation in Progress...',
message: `Blog content is being generated (${mediumPolling?.currentStatus}). Please wait for completion.`,
priority: 'high'
});
return items;
}
// Normal workflow suggestions based on current state
if (!research) {
items.push({ title: '🔎 Start research', message: "I want to research a topic for my blog" });
items.push({
title: '🔎 Start Research',
message: "I want to research a topic for my blog",
priority: 'high'
});
} else if (research && outline.length === 0) {
// Research completed, guide user to outline creation
items.push({
title: '🧩 Create Outline',
message: 'Let\'s proceed to create an outline based on the research results'
title: 'Next: Create Outline',
message: 'Let\'s proceed to create an outline based on the research results',
priority: 'high'
});
items.push({
title: '💬 Chat with Research Data',
@@ -26,13 +78,29 @@ export const useSuggestions = (research: BlogResearchResponse | null, outline: B
title: '🎨 Create Custom Outline',
message: 'I want to create an outline with my own specific instructions and requirements'
});
} else if (outline.length > 0) {
// Outline created, focus on content generation
} else if (outline.length > 0 && !outlineConfirmed) {
// Outline created but not confirmed - focus on outline review and confirmation
items.push({
title: 'Next: Confirm & Generate Content',
message: 'I\'m happy with the outline, let\'s generate content for all sections',
priority: 'high'
});
items.push({
title: '💬 Chat with Outline',
message: 'I want to discuss the outline and get insights about the content structure'
});
items.push({
title: '🔧 Refine Outline',
message: 'I want to refine the outline structure based on my feedback'
});
items.push({
title: '⚖️ Rebalance Word Counts',
message: 'Rebalance word count distribution across sections'
});
} else if (outline.length > 0 && outlineConfirmed) {
// Outline confirmed, focus on content generation
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
outline.forEach(s => items.push({ title: `✍️ Generate ${s.heading}`, message: `Generate the section: ${s.heading}` }));
items.push({ title: '🔧 Refine outline', message: 'Help me refine the outline structure' });
items.push({ title: '✨ Enhance outline', message: 'Optimize the entire outline for better flow and engagement' });
items.push({ title: '⚖️ Rebalance word counts', message: 'Rebalance word count distribution across sections' });
items.push({ title: '📈 Run SEO analysis', message: 'Analyze SEO for my blog post' });
items.push({ title: '🧾 Generate SEO metadata', message: 'Generate SEO metadata and title' });
items.push({ title: '🧪 Hallucination check', message: 'Check for any false claims in my content' });
@@ -40,11 +108,11 @@ export const useSuggestions = (research: BlogResearchResponse | null, outline: B
}
return items;
}, [research, outline]);
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling]);
};
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline }) => {
const suggestions = useSuggestions(research, outline);
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
useSuggestions(research, outline, outlineConfirmed);
return null; // This is just a utility component
};

View File

@@ -0,0 +1,293 @@
import React, { useState, useCallback, useEffect } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, TextField, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider } from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
} from '@mui/icons-material';
import { BlogOutlineSection, BlogResearchResponse } from '../../../services/blogWriterApi';
import BlogSection from './BlogSection';
// Helper to create a consistent theme
const theme = createTheme({
typography: {
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
},
palette: {
primary: {
main: '#4f46e5',
},
},
});
interface BlogEditorProps {
outline: BlogOutlineSection[];
research: BlogResearchResponse | null;
initialTitle?: string;
titleOptions?: string[];
researchTitles?: string[];
aiGeneratedTitles?: string[];
sections?: Record<string, string>;
onContentUpdate?: (sections: any[]) => void;
onSave?: (content: any) => void;
}
const BlogEditor: React.FC<BlogEditorProps> = ({
outline,
research,
initialTitle,
titleOptions = [],
researchTitles = [],
aiGeneratedTitles = [],
sections: parentSections,
onContentUpdate,
onSave
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [sections, setSections] = useState<any[]>([]);
const [isTitleLoading, setIsTitleLoading] = useState(false);
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
const [showTitleModal, setShowTitleModal] = useState(false);
// Initialize sections from outline or use parent sections
useEffect(() => {
if (outline && outline.length > 0) {
const initialSections = outline.map((section, index) => ({
id: section.id || index + 1,
title: section.heading,
content: parentSections?.[section.id] || section.key_points?.join(' ') || '',
wordCount: section.target_words || 0,
sources: section.references?.length || 0,
outlineData: {
subheadings: section.subheadings || [],
keyPoints: section.key_points || [],
keywords: section.keywords || [],
references: section.references || [],
targetWords: section.target_words || 0
}
}));
setSections(initialSections);
}
}, [outline, parentSections]);
// Initialize title from parent when provided
useEffect(() => {
if (initialTitle && initialTitle.trim().length > 0) {
setBlogTitle(initialTitle);
}
}, [initialTitle]);
const handleSuggestTitle = useCallback(() => {
console.log('Available titles:', { researchTitles, aiGeneratedTitles, titleOptions });
setShowTitleModal(true);
}, [researchTitles, aiGeneratedTitles, titleOptions]);
const handleTitleSelect = useCallback((selectedTitle: string) => {
setBlogTitle(selectedTitle);
setShowTitleModal(false);
}, []);
const toggleSectionExpansion = useCallback((sectionId: any) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
if (newSet.has(sectionId)) {
newSet.delete(sectionId);
} else {
newSet.add(sectionId);
}
return newSet;
});
}, []);
// Main Render - Exactly like your example
return (
<ThemeProvider theme={theme}>
<div className="bg-gray-50 min-h-screen font-sans">
<main className="w-full px-4 sm:px-6 lg:px-8 py-8">
<div className="w-full max-w-4xl mx-auto">
<Paper elevation={0} className="bg-white p-8 md:p-12 rounded-xl border border-gray-200/80 w-full">
<div className="mb-8 pb-6 border-b">
<div className="flex items-start gap-2 group">
<h1
className="flex-1 text-2xl md:text-4xl font-bold font-serif text-gray-900 leading-tight cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
style={{
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
lineHeight: '1.3'
}}
onClick={() => {
const newTitle = prompt('Edit blog title:', blogTitle);
if (newTitle !== null) {
setBlogTitle(newTitle);
}
}}
title="Click to edit title"
>
{blogTitle}
</h1>
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 mt-1">
<Tooltip title="✨ ALwrity it">
<IconButton onClick={handleSuggestTitle} disabled={isTitleLoading} size="small">
{isTitleLoading ? <CircularProgress size={20} /> : <AutoAwesomeIcon className="text-purple-500" fontSize="small"/>}
</IconButton>
</Tooltip>
</div>
</div>
<p className="mt-3 text-gray-500 text-sm">
This is where your blog's subtitle or a brief one-line description will appear. It's editable too!
</p>
<Divider sx={{ mt: 3, opacity: 0.3 }} />
</div>
<div>
{sections.map((section) => (
<BlogSection
key={section.id}
{...section}
onContentUpdate={onContentUpdate}
expandedSections={expandedSections}
toggleSectionExpansion={toggleSectionExpansion}
/>
))}
</div>
</Paper>
</div>
</main>
{/* Title Selection Modal */}
<Dialog
open={showTitleModal}
onClose={() => setShowTitleModal(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
Choose Your Blog Title
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{/* Research Titles */}
{researchTitles.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'primary.main' }}>
📊 Research-Based Titles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{researchTitles.map((title, index) => (
<Button
key={index}
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'primary.light',
color: 'white',
}
}}
>
{title}
</Button>
))}
</Box>
</Box>
)}
{/* AI Generated Titles */}
{aiGeneratedTitles.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'secondary.main' }}>
🤖 AI Generated Titles
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{aiGeneratedTitles.map((title, index) => (
<Button
key={index}
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'secondary.light',
color: 'white',
}
}}
>
{title}
</Button>
))}
</Box>
</Box>
)}
{/* Title Options */}
{titleOptions.length > 0 && (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', mb: 2, color: 'success.main' }}>
Additional Options
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{titleOptions.map((title, index) => (
<Button
key={index}
variant="outlined"
fullWidth
onClick={() => handleTitleSelect(title)}
sx={{
justifyContent: 'flex-start',
textAlign: 'left',
textTransform: 'none',
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'success.light',
color: 'white',
}
}}
>
{title}
</Button>
))}
</Box>
</Box>
)}
{researchTitles.length === 0 && aiGeneratedTitles.length === 0 && titleOptions.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No title options available. Please generate an outline first.
</Typography>
)}
{/* Debug info */}
{process.env.NODE_ENV === 'development' && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.100', borderRadius: 1 }}>
<Typography variant="caption" color="text.secondary">
Debug: Research titles: {researchTitles.length}, AI titles: {aiGeneratedTitles.length}, Options: {titleOptions.length}
</Typography>
</Box>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowTitleModal(false)}>
Cancel
</Button>
</DialogActions>
</Dialog>
</div>
</ThemeProvider>
);
};
export default BlogEditor;

View File

@@ -0,0 +1,373 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Paper,
IconButton,
Chip,
TextField,
Tooltip,
CircularProgress,
Divider,
Button
} from '@mui/material';
import {
Edit as EditIcon,
DeleteOutline as DeleteOutlineIcon,
FileCopyOutlined as FileCopyOutlinedIcon,
Link as LinkIcon,
AutoAwesome as AutoAwesomeIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
} from '@mui/icons-material';
import useBlogTextSelectionHandler from './BlogTextSelectionHandler';
interface BlogSectionProps {
id: any;
title: string;
content: string;
wordCount: number;
sources: number;
outlineData?: {
subheadings: string[];
keyPoints: string[];
keywords: string[];
references: any[];
targetWords: number;
};
onContentUpdate?: (sections: any[]) => void;
expandedSections: Set<any>;
toggleSectionExpansion: (sectionId: any) => void;
}
const BlogSection: React.FC<BlogSectionProps> = ({
id,
title,
content: initialContent,
wordCount,
sources,
outlineData,
onContentUpdate,
expandedSections,
toggleSectionExpansion
}) => {
const [isEditing, setIsEditing] = useState(false);
const [sectionTitle, setSectionTitle] = useState(title);
const [content, setContent] = useState(initialContent);
const [isGenerating, setIsGenerating] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
// Initialize assistive writing handler
const assistiveWriting = useBlogTextSelectionHandler(
contentRef,
(originalText: string, newText: string, editType: string) => {
// Handle text replacement in the textarea
if (contentRef.current) {
const textarea = contentRef.current;
const currentContent = textarea.value;
const updatedContent = currentContent.replace(originalText, newText);
setContent(updatedContent);
// Update parent state
if (onContentUpdate) {
onContentUpdate([{ id, content: updatedContent }]);
}
// Focus back to textarea and set cursor after the replaced text
setTimeout(() => {
if (contentRef.current) {
const newCursorPosition = updatedContent.indexOf(newText) + newText.length;
contentRef.current.focus();
contentRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
}
}, 100);
}
}
);
// Format content helper - ensures proper paragraph breaks
const formatContent = (rawContent: string) => {
if (!rawContent) return rawContent;
// Ensure double line breaks between paragraphs
// Replace single line breaks with double line breaks if they're not already double
let formatted = rawContent
.replace(/\n{3,}/g, '\n\n') // Replace 3+ line breaks with double
.replace(/\n(?!\n)/g, '\n\n') // Replace single line breaks with double
.trim();
return formatted;
};
// Sync content when initialContent changes (e.g., from AI generation)
useEffect(() => {
if (initialContent !== content) {
const formattedContent = formatContent(initialContent);
setContent(formattedContent);
}
}, [initialContent]);
const handleContentChange = (e: any) => {
const newContent = e.target.value;
setContent(newContent);
// Trigger smart typing assist
assistiveWriting.handleTypingChange(newContent);
};
const handleFocus = () => setIsFocused(true);
const handleBlur = () => setIsFocused(false);
const handleGenerateContent = async () => {
setIsGenerating(true);
try {
// This would call your AI service for content generation
await new Promise(resolve => setTimeout(resolve, 2000));
const generated = `This is AI-generated content for "${sectionTitle}" with engaging, well-structured paragraphs grounded in your research.`;
setContent(generated);
// Update parent state if needed
if (onContentUpdate) {
onContentUpdate([{ id, content: generated }]);
}
} catch (error) {
console.error('Failed to generate content:', error);
} finally {
setIsGenerating(false);
}
};
return (
<div
className="group relative mb-6"
id={`section-${id}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isEditing ? (
<TextField
fullWidth
variant="standard"
value={sectionTitle}
onChange={(e) => setSectionTitle(e.target.value)}
onBlur={() => setIsEditing(false)}
autoFocus
InputProps={{ className: 'text-2xl md:text-3xl font-bold !font-serif text-gray-800 mb-4' }}
/>
) : (
<h2
className="text-2xl md:text-3xl font-bold font-serif text-gray-800 mb-4 cursor-pointer"
onClick={() => setIsEditing(true)}
>
{sectionTitle}
</h2>
)}
<div
className="relative"
onMouseUp={assistiveWriting.handleTextSelection}
onKeyUp={assistiveWriting.handleTextSelection}
>
<TextField
multiline
fullWidth
variant="standard"
value={content}
onChange={handleContentChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder="Start writing your section here... Select text for assistive writing features!"
InputProps={{
disableUnderline: true,
className: 'text-gray-600 leading-relaxed text-base md:text-lg focus-within:bg-indigo-50/50 p-2 rounded-md transition-colors duration-200',
style: {
whiteSpace: 'pre-wrap', // Preserve line breaks and spaces
lineHeight: '1.8', // Better line spacing for readability
},
}}
inputRef={contentRef}
/>
{/* Render assistive writing selection menu */}
{assistiveWriting.renderSelectionMenu()}
{/* Simple AI generation button - only show when no text selection menu is active */}
{content && isFocused && !assistiveWriting.selectionMenu && (
<div
className="absolute z-10"
style={{
right: '8px',
bottom: '8px',
}}
>
<Tooltip title="✨ Generate Content">
<IconButton
size="small"
onClick={handleGenerateContent}
disabled={isGenerating}
sx={{
background: 'rgba(79, 70, 229, 0.1)',
color: '#4f46e5',
border: '1px solid rgba(79, 70, 229, 0.2)',
'&:hover': {
background: 'rgba(79, 70, 229, 0.2)',
transform: 'translateY(-1px)',
},
'&:disabled': {
background: 'rgba(255, 255, 255, 0.7)',
color: '#9CA3AF',
},
}}
>
{isGenerating ? (
<CircularProgress size={16} color="inherit" />
) : (
<AutoAwesomeIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
</div>
)}
</div>
{/* Outline Information Section */}
{outlineData && expandedSections.has(id) && (
<div className="mt-4">
<Paper elevation={0} sx={{ p: 2, bgcolor: '#f8f9fa', borderRadius: 2, mb: 2 }}>
<div className="flex flex-col gap-4">
{/* Key Points */}
{outlineData.keyPoints && outlineData.keyPoints.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Key Points:</div>
<div className="flex flex-wrap gap-1">
{outlineData.keyPoints.map((point: any, index: any) => (
<Chip
key={index}
label={point}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
))}
</div>
</div>
)}
{/* Subheadings */}
{outlineData.subheadings && outlineData.subheadings.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Subheadings:</div>
<div className="flex flex-wrap gap-1">
{outlineData.subheadings.map((subheading: any, index: any) => (
<Chip
key={index}
label={subheading}
size="small"
variant="outlined"
color="secondary"
sx={{ fontSize: '0.75rem' }}
/>
))}
</div>
</div>
)}
{/* Keywords */}
{outlineData.keywords && outlineData.keywords.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">Keywords:</div>
<div className="flex flex-wrap gap-1">
{outlineData.keywords.map((keyword: any, index: any) => (
<Chip
key={index}
label={keyword}
size="small"
variant="filled"
color="primary"
sx={{ fontSize: '0.75rem' }}
/>
))}
</div>
</div>
)}
{/* Target Words */}
{outlineData.targetWords > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">
Target Words: {outlineData.targetWords}
</div>
</div>
)}
{/* References */}
{outlineData.references && outlineData.references.length > 0 && (
<div>
<div className="text-sm font-bold text-blue-600 mb-2">
References ({outlineData.references.length}):
</div>
<div className="flex flex-wrap gap-1">
{outlineData.references.slice(0, 3).map((ref: any, index: any) => (
<Chip
key={index}
label={ref.title || `Source ${index + 1}`}
size="small"
variant="outlined"
color="info"
sx={{ fontSize: '0.75rem' }}
/>
))}
{outlineData.references.length > 3 && (
<Chip
label={`+${outlineData.references.length - 3} more`}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
)}
</div>
</div>
)}
</div>
</Paper>
</div>
)}
<div className="absolute -bottom-4 right-0 flex items-center space-x-1" style={{ opacity: isHovered ? 1 : 0, transition: 'opacity 0.3s' }}>
<Chip label={`${content.split(' ').length} words`} size="small" variant="outlined" className="!text-gray-500" />
<Chip icon={<LinkIcon />} label={`${sources} sources`} size="small" variant="outlined" className="!text-gray-500" />
{outlineData && (
<Chip
icon={expandedSections.has(id) ? <ExpandLessIcon /> : <ExpandMoreIcon />}
label="Outline Info"
size="small"
variant="outlined"
clickable
onClick={() => toggleSectionExpansion(id)}
sx={{
fontSize: '0.75rem',
'&:hover': {
backgroundColor: 'rgba(25, 118, 210, 0.08)',
}
}}
/>
)}
<Tooltip title="Generate Content">
<IconButton size="small" onClick={handleGenerateContent}>
<AutoAwesomeIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Copy Section"><IconButton size="small"><FileCopyOutlinedIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Edit Metadata"><IconButton size="small"><EditIcon fontSize="small" /></IconButton></Tooltip>
<Tooltip title="Delete Section"><IconButton size="small" className="text-red-500"><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
</div>
{/* Section Divider */}
<Divider sx={{ mt: 1.2, mb: 1, opacity: 0.3 }} />
</div>
);
};
export default BlogSection;

View File

@@ -0,0 +1,793 @@
import React, { useState, useRef, useEffect } from 'react';
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
import FactCheckResults from '../../LinkedInWriter/components/FactCheckResults';
interface BlogTextSelectionHandlerProps {
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
onTextReplace?: (originalText: string, newText: string, editType: string) => void;
}
const useBlogTextSelectionHandler = (
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>,
onTextReplace?: (originalText: string, newText: string, editType: string) => void
) => {
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string } | null>(null);
const [factCheckResults, setFactCheckResults] = useState<HallucinationDetectionResponse | null>(null);
const [isFactChecking, setIsFactChecking] = useState(false);
const [factCheckProgress, setFactCheckProgress] = useState<{ step: string; progress: number } | null>(null);
const selectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Smart typing assist states
const [smartSuggestion, setSmartSuggestion] = useState<{ text: string; position: { x: number; y: number } } | null>(null);
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Fact-checking functionality
const handleCheckFacts = async (text: string) => {
console.log('🔍 [BlogTextSelectionHandler] handleCheckFacts called with text:', text);
if (!text.trim()) {
console.log('🔍 [BlogTextSelectionHandler] No text to check, returning');
return;
}
console.log('🔍 [BlogTextSelectionHandler] Starting fact check for:', text.trim());
setIsFactChecking(true);
setSelectionMenu(null);
// Progress tracking
const progressSteps = [
{ step: "Extracting verifiable claims...", progress: 20 },
{ step: "Searching for evidence...", progress: 40 },
{ step: "Analyzing claims against sources...", progress: 70 },
{ step: "Generating final assessment...", progress: 90 },
{ step: "Completing fact-check...", progress: 100 }
];
let currentStepIndex = 0;
// Start progress updates
const progressInterval = setInterval(() => {
if (currentStepIndex < progressSteps.length) {
setFactCheckProgress(progressSteps[currentStepIndex]);
currentStepIndex++;
}
}, 2000); // Update every 2 seconds
// Set a timeout for the fact check (30 seconds)
const timeoutId = setTimeout(() => {
console.log('🔍 [BlogTextSelectionHandler] Fact check timeout reached');
clearInterval(progressInterval);
setFactCheckProgress(null);
setIsFactChecking(false);
setFactCheckResults({
success: false,
claims: [],
overall_confidence: 0,
total_claims: 0,
supported_claims: 0,
refuted_claims: 0,
insufficient_claims: 0,
timestamp: new Date().toISOString(),
error: 'Fact check timed out after 30 seconds. Please try again with shorter text.'
});
}, 30000); // 30 second timeout
try {
console.log('🔍 [BlogTextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
const results = await hallucinationDetectorService.detectHallucinations({
text: text.trim(),
include_sources: true,
max_claims: 10
});
console.log('🔍 [BlogTextSelectionHandler] Fact check results received:', results);
setFactCheckResults(results);
} catch (error) {
console.error('🔍 [BlogTextSelectionHandler] Error checking facts:', error);
setFactCheckResults({
success: false,
claims: [],
overall_confidence: 0,
total_claims: 0,
supported_claims: 0,
refuted_claims: 0,
insufficient_claims: 0,
timestamp: new Date().toISOString(),
error: `Failed to check facts: ${error instanceof Error ? error.message : 'Unknown error'}`
});
} finally {
console.log('🔍 [BlogTextSelectionHandler] Fact check completed, setting isFactChecking to false');
clearInterval(progressInterval);
clearTimeout(timeoutId);
setFactCheckProgress(null);
setIsFactChecking(false);
}
};
const handleCloseFactCheckResults = () => {
setFactCheckResults(null);
};
// Blog-specific quick edit functionality for selected text
const handleQuickEdit = (editType: string, selectedText: string) => {
console.log('🔍 [BlogTextSelectionHandler] handleQuickEdit called:', editType, selectedText);
let editedText = selectedText;
switch (editType) {
case 'improve':
// Enhance readability and engagement
editedText = selectedText.replace(/\./g, '. ').replace(/\s+/g, ' ').trim();
if (!editedText.endsWith('.') && !editedText.endsWith('!') && !editedText.endsWith('?')) {
editedText += '.';
}
break;
case 'add-transition':
// Add transitional phrases
const transitions = ['Furthermore,', 'Additionally,', 'Moreover,', 'In essence,', 'As a result,'];
const randomTransition = transitions[Math.floor(Math.random() * transitions.length)];
editedText = `${randomTransition} ${selectedText.toLowerCase()}`;
break;
case 'shorten':
// Condense while maintaining meaning
editedText = selectedText
.replace(/\b(very|really|extremely|quite|rather|fairly)\s+/gi, '')
.replace(/\b(that|which) (is|are|was|were)\s+/gi, '')
.replace(/\bin order to\b/gi, 'to')
.replace(/\bdue to the fact that\b/gi, 'because')
.trim();
break;
case 'expand':
// Add explanatory content
editedText = selectedText + ' This approach provides significant value by offering concrete benefits and actionable insights that readers can immediately implement.';
break;
case 'professionalize':
// Make more formal and professional
editedText = selectedText
.replace(/\bcan't\b/gi, 'cannot')
.replace(/\bwon't\b/gi, 'will not')
.replace(/\bdon't\b/gi, 'do not')
.replace(/\bisn't\b/gi, 'is not')
.replace(/\baren't\b/gi, 'are not')
.replace(/\bI think\b/gi, 'It is evident that')
.replace(/\bI believe\b/gi, 'Research indicates that');
break;
case 'add-data':
// Add statistical backing
editedText = selectedText + ' According to recent industry studies, this approach has shown measurable improvements in key performance metrics.';
break;
default:
return;
}
// Call the callback with the edited text
if (onTextReplace) {
onTextReplace(selectedText, editedText, editType);
}
// Also dispatch custom event for broader compatibility
window.dispatchEvent(new CustomEvent('blogwriter:replaceSelectedText', {
detail: {
originalText: selectedText,
editedText: editedText,
editType: editType
}
}));
// Close the selection menu
setSelectionMenu(null);
};
// Smart typing assist functionality
const generateSmartSuggestion = async (currentText: string) => {
if (currentText.length < 20) return; // Only suggest after some meaningful content
setIsGeneratingSuggestion(true);
try {
// Simulate AI generation with contextual suggestions
await new Promise(resolve => setTimeout(resolve, 1500));
const suggestions = [
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
"Research indicates that this strategy has proven effective across multiple industries and use cases.",
"Furthermore, this method demonstrates measurable improvements in key performance indicators.",
"Additionally, industry experts recommend this technique for sustainable long-term growth.",
"Moreover, this framework addresses common challenges while providing practical solutions."
];
const randomSuggestion = suggestions[Math.floor(Math.random() * suggestions.length)];
// Get cursor position for suggestion placement
if (contentRef.current) {
const element = contentRef.current;
const rect = element.getBoundingClientRect();
const x = rect.left + 20;
const y = rect.bottom + 5;
setSmartSuggestion({
text: randomSuggestion,
position: { x, y }
});
}
} catch (error) {
console.error('Failed to generate smart suggestion:', error);
} finally {
setIsGeneratingSuggestion(false);
}
};
const handleTypingChange = (newText: string) => {
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Clear any existing suggestion when user types
setSmartSuggestion(null);
// Set new timeout for suggestion generation
typingTimeoutRef.current = setTimeout(() => {
// First time suggestion appears automatically
if (!hasShownFirstSuggestion && newText.length > 20) {
generateSmartSuggestion(newText);
setHasShownFirstSuggestion(true);
}
// After first time, only suggest after longer pauses or more content
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
generateSmartSuggestion(newText);
}
}, 3000); // 3 second pause before suggesting
};
const handleAcceptSuggestion = () => {
if (smartSuggestion && onTextReplace && contentRef.current) {
const element = contentRef.current;
const currentContent = (element as HTMLTextAreaElement).value || (element as HTMLDivElement).textContent || '';
const newContent = currentContent + ' ' + smartSuggestion.text;
// Use the text replacement callback
onTextReplace(currentContent, newContent, 'smart-suggestion');
setSmartSuggestion(null);
}
};
const handleRejectSuggestion = () => {
setSmartSuggestion(null);
};
// Cleanup progress and timeouts on unmount
useEffect(() => {
return () => {
setFactCheckProgress(null);
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
};
}, []);
// Text selection handler with debouncing
const handleTextSelection = () => {
console.log('🔍 [BlogTextSelectionHandler] handleTextSelection called');
// Clear any existing timeout
if (selectionTimeoutRef.current) {
clearTimeout(selectionTimeoutRef.current);
}
// Debounce the selection handling
selectionTimeoutRef.current = setTimeout(() => {
try {
const sel = window.getSelection();
console.log('🔍 [BlogTextSelectionHandler] Selection object (debounced):', sel);
if (!sel || sel.rangeCount === 0) {
console.log('🔍 [BlogTextSelectionHandler] No selection or range count is 0');
setSelectionMenu(null);
return;
}
const text = (sel.toString() || '').trim();
console.log('🔍 [BlogTextSelectionHandler] Selected text (debounced):', text, 'Length:', text.length);
if (!text || text.length < 10) {
console.log('🔍 [BlogTextSelectionHandler] Text too short or empty, hiding menu');
setSelectionMenu(null);
return;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
console.log('🔍 [BlogTextSelectionHandler] Range rect:', rect);
// Use viewport coordinates for absolute positioning
const x = Math.max(8, Math.min(rect.left + (rect.width / 2), window.innerWidth - 280)); // Account for menu width
const y = Math.max(8, rect.top + window.scrollY);
const menuPosition = { x, y, text };
console.log('🔍 [BlogTextSelectionHandler] Setting selection menu at position (debounced):', menuPosition);
setSelectionMenu(menuPosition);
} catch (error) {
console.error('🔍 [BlogTextSelectionHandler] Error handling text selection:', error);
setSelectionMenu(null);
}
}, 150); // 150ms debounce
};
return {
selectionMenu,
setSelectionMenu,
factCheckResults,
isFactChecking,
factCheckProgress,
smartSuggestion,
isGeneratingSuggestion,
handleTextSelection,
handleCheckFacts,
handleCloseFactCheckResults,
handleQuickEdit,
handleTypingChange,
handleAcceptSuggestion,
handleRejectSuggestion,
// Render the selection menu and fact-check components
renderSelectionMenu: () => (
<>
{/* Text Selection Menu */}
{selectionMenu && (
<div
onClick={(e) => {
console.log('🔍 [BlogTextSelectionHandler] Selection menu clicked!', e.target);
e.stopPropagation();
}}
style={{
position: 'fixed',
top: selectionMenu.y - 60,
left: Math.max(8, selectionMenu.x - 140),
background: 'rgba(79, 70, 229, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
gap: '6px',
padding: '12px 16px',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.35)',
backdropFilter: 'blur(12px)',
zIndex: 10000,
minWidth: '240px',
maxWidth: '280px'
}}
>
{/* Fact Check Button */}
<button
onClick={(e) => {
console.log('🔍 [BlogTextSelectionHandler] Check Facts button clicked!', selectionMenu.text);
e.preventDefault();
e.stopPropagation();
handleCheckFacts(selectionMenu.text);
}}
disabled={isFactChecking}
style={{
background: isFactChecking ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '8px',
padding: '8px 16px',
color: 'white',
fontSize: '13px',
fontWeight: '600',
cursor: isFactChecking ? 'not-allowed' : 'pointer',
opacity: isFactChecking ? 0.6 : 1,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
width: '100%',
justifyContent: 'center'
}}
onMouseEnter={(e) => {
if (!isFactChecking) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}
}}
onMouseLeave={(e) => {
if (!isFactChecking) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}
}}
>
{isFactChecking ? (
<>
<div style={{
width: '14px',
height: '14px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
Fact-checking...
</>
) : (
<>
🔍 Fact Check
</>
)}
</button>
{/* Quick Edit Options */}
<div style={{
borderTop: '1px solid rgba(255, 255, 255, 0.2)',
paddingTop: '10px',
marginTop: '6px'
}}>
<div style={{
color: 'rgba(255, 255, 255, 0.9)',
fontSize: '11px',
fontWeight: '600',
marginBottom: '8px',
textAlign: 'center',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Assistive Writing
</div>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '6px'
}}>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('improve', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Improve
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('add-transition', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
🔗 Transition
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('shorten', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Shorten
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('expand', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
📝 Expand
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('professionalize', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
🎓 Professional
</button>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleQuickEdit('add-data', selectionMenu.text);
}}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 10px',
color: 'white',
fontSize: '11px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
📊 Add Data
</button>
</div>
</div>
</div>
)}
{/* Fact Check Progress */}
{factCheckProgress && (
<div style={{
position: 'fixed',
top: '20px',
right: '20px',
background: 'rgba(79, 70, 229, 0.95)',
color: 'white',
padding: '12px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
display: 'flex',
alignItems: 'center',
gap: '12px',
minWidth: '280px'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<div>
<div style={{ fontSize: '13px', fontWeight: '600' }}>
Fact-checking content...
</div>
<div style={{ fontSize: '11px', opacity: 0.8 }}>
{factCheckProgress.step}
</div>
</div>
</div>
)}
{/* Fact Check Results */}
{factCheckResults && (
<FactCheckResults
results={factCheckResults}
onClose={handleCloseFactCheckResults}
/>
)}
{/* Smart Typing Suggestion */}
{smartSuggestion && (
<div
style={{
position: 'fixed',
top: smartSuggestion.position.y,
left: smartSuggestion.position.x,
background: 'rgba(34, 197, 94, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.25)',
borderRadius: '12px',
padding: '16px 20px',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(12px)',
zIndex: 10002,
maxWidth: '400px',
minWidth: '320px',
color: 'white'
}}
>
<div style={{
fontSize: '12px',
fontWeight: '600',
marginBottom: '8px',
opacity: 0.9,
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
Smart Writing Suggestion
</div>
<div style={{
fontSize: '14px',
lineHeight: '1.4',
marginBottom: '16px',
fontStyle: 'italic'
}}>
"{smartSuggestion.text}"
</div>
<div style={{
display: 'flex',
gap: '8px',
justifyContent: 'flex-end'
}}>
<button
onClick={handleRejectSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.15)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '6px',
padding: '6px 12px',
color: 'white',
fontSize: '12px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
}}
>
Dismiss
</button>
<button
onClick={handleAcceptSuggestion}
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '6px',
padding: '6px 12px',
color: 'white',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
}}
>
Accept
</button>
</div>
</div>
)}
{/* Smart Suggestion Loading Indicator */}
{isGeneratingSuggestion && (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
background: 'rgba(34, 197, 94, 0.95)',
color: 'white',
padding: '12px 20px',
borderRadius: '12px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
backdropFilter: 'blur(10px)',
zIndex: 10001,
display: 'flex',
alignItems: 'center',
gap: '12px',
minWidth: '240px'
}}>
<div style={{
width: '20px',
height: '20px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
<div>
<div style={{ fontSize: '13px', fontWeight: '600' }}>
Generating suggestion...
</div>
<div style={{ fontSize: '11px', opacity: 0.8 }}>
AI is crafting helpful content
</div>
</div>
</div>
)}
{/* CSS for spinner animation */}
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</>
)
};
};
export default useBlogTextSelectionHandler;
export type { BlogTextSelectionHandlerProps };

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Paper, Button, Chip } from '@mui/material';
import {
Add as AddIcon,
AutoAwesome as AutoAwesomeIcon,
BarChart as BarChartIcon,
Hub as HubIcon,
GpsFixed as GpsFixedIcon,
} from '@mui/icons-material';
interface EditorSidebarProps {
sections: any[];
totalWords: number;
}
const EditorSidebar: React.FC<EditorSidebarProps> = ({ sections, totalWords }) => {
return (
<div className="sticky top-24 hidden lg:block">
<Paper elevation={2} className="p-4 rounded-xl shadow-lg border border-gray-100">
<h3 className="font-bold text-lg mb-4 text-gray-700">Editor's Toolkit</h3>
<div className="space-y-3 mb-6">
<Button
fullWidth
variant="contained"
startIcon={<AutoAwesomeIcon />}
className="!bg-gradient-to-r !from-indigo-500 !to-purple-500 !capitalize !font-semibold !rounded-lg"
>
ALwrity it
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<AddIcon />}
className="!capitalize !rounded-lg"
>
Add Section
</Button>
</div>
<div className="mb-6">
<h4 className="font-semibold text-sm text-gray-600 mb-3">Outline</h4>
<ul className="space-y-2">
{sections.map(section => (
<li key={section.id}>
<a
href={`#section-${section.id}`}
className="text-sm text-gray-500 hover:text-indigo-600 transition-colors flex items-start"
>
<span className="mr-2 font-semibold">{section.id}.</span>
<span className="flex-1">{section.title}</span>
</a>
</li>
))}
</ul>
</div>
<div className="border-t pt-4">
<h4 className="font-semibold text-sm text-gray-600 mb-3">SuperPowers</h4>
<div className="flex flex-wrap gap-2">
<Chip
icon={<BarChartIcon />}
label="Research"
size="small"
clickable
variant="outlined"
/>
<Chip
icon={<HubIcon />}
label="Source Mapping"
size="small"
clickable
variant="outlined"
/>
<Chip
icon={<GpsFixedIcon />}
label="Grounding"
size="small"
clickable
variant="outlined"
/>
</div>
</div>
</Paper>
<div className="text-center text-xs text-gray-400 mt-4">
<span>{sections.length} sections</span> &bull; <span>{totalWords} words total</span>
</div>
</div>
);
};
export default EditorSidebar;

View File

@@ -0,0 +1,318 @@
import React from 'react';
import {
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Divider,
Chip
} from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
Edit as EditIcon,
Add as AddIcon,
DeleteOutline as DeleteOutlineIcon,
GpsFixed as GpsFixedIcon,
BarChart as BarChartIcon,
Link as LinkIcon,
CheckCircle as CheckCircleIcon,
ContentCopy as ContentCopyIcon,
TrendingUp as TrendingUpIcon,
} from '@mui/icons-material';
interface HoverMenuProps {
anchorEl: HTMLElement | null;
open: boolean;
onClose: () => void;
type: 'title' | 'section' | 'content';
onAction: (action: string) => void;
context?: {
sectionId?: string;
hasContent?: boolean;
sources?: number;
wordCount?: number;
};
}
const HoverMenu: React.FC<HoverMenuProps> = ({
anchorEl,
open,
onClose,
type,
onAction,
context
}) => {
const handleAction = (action: string) => {
onAction(action);
onClose();
};
// Don't render if anchor is invalid
if (!anchorEl || !open) {
return null;
}
const getTitleMenuItems = () => [
{
icon: <AutoAwesomeIcon fontSize="small" />,
text: 'Generate Alternative Titles',
action: 'generate-titles',
description: 'AI-powered title variations'
},
{
icon: <TrendingUpIcon fontSize="small" />,
text: 'SEO Optimization',
action: 'seo-optimize',
description: 'Keyword density and optimization'
},
{
icon: <BarChartIcon fontSize="small" />,
text: 'A/B Testing',
action: 'ab-test',
description: 'Create multiple title versions'
},
{
icon: <GpsFixedIcon fontSize="small" />,
text: 'Research-Based Titles',
action: 'research-titles',
description: 'Titles based on research findings'
}
];
const getSectionMenuItems = () => [
{
icon: <AutoAwesomeIcon fontSize="small" />,
text: 'Generate Content',
action: 'generate-content',
description: 'AI content generation for this section'
},
{
icon: <EditIcon fontSize="small" />,
text: 'Enhance Section',
action: 'enhance-section',
description: 'Improve existing content with AI'
},
{
icon: <AddIcon fontSize="small" />,
text: 'Add Subsection',
action: 'add-subsection',
description: 'Insert new content blocks'
},
{
icon: <CheckCircleIcon fontSize="small" />,
text: 'Fact Check',
action: 'fact-check',
description: 'Verify claims against research data'
},
{
icon: <LinkIcon fontSize="small" />,
text: 'Source Mapping',
action: 'source-mapping',
description: 'Link content to research sources'
},
{
icon: <TrendingUpIcon fontSize="small" />,
text: 'SEO Analysis',
action: 'seo-analysis',
description: 'Section-level SEO optimization'
}
];
const getContentMenuItems = () => [
{
icon: <AutoAwesomeIcon fontSize="small" />,
text: 'Continue Writing',
action: 'continue-writing',
description: 'AI-powered content continuation'
},
{
icon: <EditIcon fontSize="small" />,
text: 'Improve Clarity',
action: 'improve-clarity',
description: 'Enhance readability and flow'
},
{
icon: <AddIcon fontSize="small" />,
text: 'Add Examples',
action: 'add-examples',
description: 'Insert relevant examples and case studies'
},
{
icon: <LinkIcon fontSize="small" />,
text: 'Cite Sources',
action: 'cite-sources',
description: 'Add research-backed citations'
},
{
icon: <TrendingUpIcon fontSize="small" />,
text: 'Optimize for SEO',
action: 'optimize-seo',
description: 'Keyword optimization suggestions'
}
];
const getMenuItems = () => {
switch (type) {
case 'title':
return getTitleMenuItems();
case 'section':
return getSectionMenuItems();
case 'content':
return getContentMenuItems();
default:
return [];
}
};
const menuItems = getMenuItems();
return (
<Menu
anchorEl={anchorEl}
open={open}
onClose={onClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
PaperProps={{
sx: {
minWidth: 280,
maxWidth: 320,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
border: '1px solid rgba(0, 0, 0, 0.08)',
borderRadius: '12px',
}
}}
>
{/* Context Information */}
{context && (
<>
<div className="px-4 py-2 bg-gray-50 border-b">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600 uppercase tracking-wide">
{type} Actions
</span>
{context.wordCount && (
<Chip
label={`${context.wordCount} words`}
size="small"
variant="outlined"
className="!text-xs"
/>
)}
</div>
{context.sources && (
<div className="flex items-center gap-1 mt-1">
<LinkIcon fontSize="small" className="text-gray-400" />
<span className="text-xs text-gray-500">{context.sources} sources available</span>
</div>
)}
</div>
</>
)}
{/* Menu Items */}
{menuItems.map((item, index) => (
<MenuItem
key={item.action}
onClick={() => handleAction(item.action)}
sx={{
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'rgba(79, 70, 229, 0.04)',
}
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.text}
secondary={item.description}
primaryTypographyProps={{
fontSize: '14px',
fontWeight: 500,
color: 'text.primary'
}}
secondaryTypographyProps={{
fontSize: '12px',
color: 'text.secondary',
lineHeight: 1.3
}}
/>
</MenuItem>
))}
{/* Additional Actions */}
{type === 'section' && (
<>
<Divider />
<MenuItem
onClick={() => handleAction('copy-section')}
sx={{
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'rgba(79, 70, 229, 0.04)',
}
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<ContentCopyIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary="Copy Section"
secondary="Duplicate this section"
primaryTypographyProps={{
fontSize: '14px',
fontWeight: 500,
color: 'text.primary'
}}
secondaryTypographyProps={{
fontSize: '12px',
color: 'text.secondary',
lineHeight: 1.3
}}
/>
</MenuItem>
<MenuItem
onClick={() => handleAction('delete-section')}
sx={{
py: 1.5,
px: 2,
'&:hover': {
backgroundColor: 'rgba(239, 68, 68, 0.04)',
}
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<DeleteOutlineIcon fontSize="small" className="text-red-500" />
</ListItemIcon>
<ListItemText
primary="Delete Section"
secondary="Remove this section permanently"
primaryTypographyProps={{
fontSize: '14px',
fontWeight: 500,
color: 'text.primary'
}}
secondaryTypographyProps={{
fontSize: '12px',
color: 'text.secondary',
lineHeight: 1.3
}}
/>
</MenuItem>
</>
)}
</Menu>
);
};
export default HoverMenu;

View File

@@ -0,0 +1,361 @@
import React, { useState, useCallback } from 'react';
import {
Paper,
Chip,
IconButton,
Dialog,
DialogTitle,
DialogContent,
Button
} from '@mui/material';
import {
Link as LinkIcon,
GpsFixed as GpsFixedIcon,
CheckCircle as CheckCircleIcon,
Warning as WarningIcon,
Info as InfoIcon,
BarChart as BarChartIcon,
Hub as HubIcon,
AutoAwesome as AutoAwesomeIcon,
Close as CloseIcon,
} from '@mui/icons-material';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
interface ResearchIntegrationProps {
research: BlogResearchResponse | null;
content: string;
onSourceInsert?: (source: any) => void;
onFactCheck?: (content: string) => void;
}
interface SourceMapping {
content: string;
sources: any[];
confidence: number;
}
const ResearchIntegration: React.FC<ResearchIntegrationProps> = ({
research,
content,
onSourceInsert,
onFactCheck
}) => {
const [sourceMapping, setSourceMapping] = useState<SourceMapping[]>([]);
const [factCheckResults, setFactCheckResults] = useState<any[]>([]);
const [showSourceDialog, setShowSourceDialog] = useState(false);
const [showFactCheckDialog, setShowFactCheckDialog] = useState(false);
// Analyze content for source mapping
const analyzeSourceMapping = useCallback(() => {
if (!research || !content) return;
// Simulate source mapping analysis
const mapping: SourceMapping[] = [
{
content: "AI healthcare market projection",
sources: research.sources?.slice(0, 2) || [],
confidence: 0.95
},
{
content: "predictive analytics in healthcare",
sources: research.sources?.slice(1, 3) || [],
confidence: 0.88
}
];
setSourceMapping(mapping);
}, [research, content]);
// Perform fact checking
const performFactCheck = useCallback(() => {
if (!research || !content) return;
// Simulate fact checking
const results = [
{
claim: "AI healthcare market is projected to reach $29 billion",
status: 'verified',
sources: research.sources?.slice(0, 2) || [],
confidence: 0.92
},
{
claim: "Predictive analytics can identify at-risk patients",
status: 'verified',
sources: research.sources?.slice(1, 3) || [],
confidence: 0.89
},
{
claim: "AI reduces administrative tasks by 50%",
status: 'needs_verification',
sources: [],
confidence: 0.45
}
];
setFactCheckResults(results);
}, [research, content]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'verified':
return <CheckCircleIcon className="text-green-500" fontSize="small" />;
case 'needs_verification':
return <WarningIcon className="text-yellow-500" fontSize="small" />;
case 'unverified':
return <WarningIcon className="text-red-500" fontSize="small" />;
default:
return <InfoIcon className="text-gray-500" fontSize="small" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'verified':
return 'success';
case 'needs_verification':
return 'warning';
case 'unverified':
return 'error';
default:
return 'default';
}
};
return (
<div className="space-y-4">
{/* Research Status Overview */}
<Paper elevation={1} className="p-4 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-sm text-gray-700">Research Integration</h4>
<div className="flex items-center gap-2">
<Chip
icon={<LinkIcon />}
label={`${research?.sources?.length || 0} sources`}
size="small"
variant="outlined"
/>
<Chip
icon={<GpsFixedIcon />}
label="Google Search"
size="small"
variant="outlined"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-indigo-600">
{sourceMapping.length}
</div>
<div className="text-xs text-gray-500">Content Mapped</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{factCheckResults.filter(r => r.status === 'verified').length}
</div>
<div className="text-xs text-gray-500">Facts Verified</div>
</div>
</div>
</Paper>
{/* Source Mapping */}
<Paper elevation={1} className="p-4 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-sm text-gray-700">Source Mapping</h4>
<Button
size="small"
startIcon={<HubIcon />}
onClick={analyzeSourceMapping}
className="!text-indigo-600"
>
Analyze
</Button>
</div>
{sourceMapping.length > 0 ? (
<div className="space-y-2">
{sourceMapping.map((mapping, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
<div className="flex-1">
<div className="text-sm font-medium text-gray-700">
{mapping.content}
</div>
<div className="text-xs text-gray-500">
{mapping.sources.length} sources {Math.round(mapping.confidence * 100)}% confidence
</div>
</div>
<Chip
label={`${Math.round(mapping.confidence * 100)}%`}
size="small"
color={mapping.confidence > 0.8 ? 'success' : 'warning'}
/>
</div>
))}
</div>
) : (
<div className="text-center py-4">
<InfoIcon className="text-gray-400 mb-2" />
<div className="text-sm text-gray-500">No source mapping available</div>
<div className="text-xs text-gray-400">Click "Analyze" to map content to sources</div>
</div>
)}
</Paper>
{/* Fact Checking */}
<Paper elevation={1} className="p-4 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-sm text-gray-700">Fact Checking</h4>
<Button
size="small"
startIcon={<CheckCircleIcon />}
onClick={performFactCheck}
className="!text-green-600"
>
Check Facts
</Button>
</div>
{factCheckResults.length > 0 ? (
<div className="space-y-2">
{factCheckResults.map((result, index) => (
<div key={index} className="flex items-start gap-3 p-2 bg-gray-50 rounded-md">
<div className="mt-1">
{getStatusIcon(result.status)}
</div>
<div className="flex-1">
<div className="text-sm text-gray-700 mb-1">
{result.claim}
</div>
<div className="flex items-center gap-2">
<Chip
label={result.status.replace('_', ' ')}
size="small"
color={getStatusColor(result.status) as any}
/>
<span className="text-xs text-gray-500">
{result.sources.length} sources {Math.round(result.confidence * 100)}% confidence
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-4">
<CheckCircleIcon className="text-gray-400 mb-2" />
<div className="text-sm text-gray-500">No fact checking results</div>
<div className="text-xs text-gray-400">Click "Check Facts" to verify claims</div>
</div>
)}
</Paper>
{/* Research Insights */}
{research && (
<Paper elevation={1} className="p-4 rounded-lg">
<h4 className="font-semibold text-sm text-gray-700 mb-3">Research Insights</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<BarChartIcon fontSize="small" className="text-indigo-500" />
<span className="text-sm text-gray-600">
Primary Keywords: {research.keyword_analysis?.primary?.join(', ') || 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<GpsFixedIcon fontSize="small" className="text-green-500" />
<span className="text-sm text-gray-600">
Search Intent: {research.keyword_analysis?.search_intent || 'N/A'}
</span>
</div>
<div className="flex items-center gap-2">
<AutoAwesomeIcon fontSize="small" className="text-purple-500" />
<span className="text-sm text-gray-600">
Content Angles: {research.suggested_angles?.length || 0} suggested
</span>
</div>
</div>
</Paper>
)}
{/* Source Dialog */}
<Dialog open={showSourceDialog} onClose={() => setShowSourceDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
<div className="flex items-center justify-between">
<span>Research Sources</span>
<IconButton onClick={() => setShowSourceDialog(false)}>
<CloseIcon />
</IconButton>
</div>
</DialogTitle>
<DialogContent>
{research?.sources?.map((source, index) => (
<Paper key={index} elevation={1} className="p-3 mb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<h5 className="font-medium text-sm text-gray-800 mb-1">
{source.title || `Source ${index + 1}`}
</h5>
<p className="text-xs text-gray-600 mb-2">
{source.url || source.excerpt || 'No description available'}
</p>
<div className="flex items-center gap-2">
<Chip label="Verified" size="small" color="success" />
<Chip label="High Relevance" size="small" variant="outlined" />
</div>
</div>
<Button
size="small"
onClick={() => onSourceInsert?.(source)}
className="!text-indigo-600"
>
Insert
</Button>
</div>
</Paper>
))}
</DialogContent>
</Dialog>
{/* Fact Check Dialog */}
<Dialog open={showFactCheckDialog} onClose={() => setShowFactCheckDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
<div className="flex items-center justify-between">
<span>Fact Check Results</span>
<IconButton onClick={() => setShowFactCheckDialog(false)}>
<CloseIcon />
</IconButton>
</div>
</DialogTitle>
<DialogContent>
{factCheckResults.map((result, index) => (
<Paper key={index} elevation={1} className="p-3 mb-3">
<div className="flex items-start gap-3">
{getStatusIcon(result.status)}
<div className="flex-1">
<h5 className="font-medium text-sm text-gray-800 mb-1">
{result.claim}
</h5>
<div className="flex items-center gap-2 mb-2">
<Chip
label={result.status.replace('_', ' ')}
size="small"
color={getStatusColor(result.status) as any}
/>
<span className="text-xs text-gray-500">
{Math.round(result.confidence * 100)}% confidence
</span>
</div>
{result.sources.length > 0 && (
<div className="text-xs text-gray-600">
Supported by {result.sources.length} sources
</div>
)}
</div>
</div>
</Paper>
))}
</DialogContent>
</Dialog>
</div>
);
};
export default ResearchIntegration;

View File

@@ -0,0 +1,3 @@
export { default as BlogEditor } from './BlogEditor';
export { default as HoverMenu } from './HoverMenu';
export { default as ResearchIntegration } from './ResearchIntegration';

View File

@@ -24,6 +24,9 @@ export const useBlogWriterState = () => {
// Separate research titles from AI-generated titles
const [researchTitles, setResearchTitles] = useState<string[]>([]);
const [aiGeneratedTitles, setAiGeneratedTitles] = useState<string[]>([]);
// Outline confirmation state
const [outlineConfirmed, setOutlineConfirmed] = useState<boolean>(false);
// Cache recovery - restore most recent research on page load
useEffect(() => {
@@ -116,6 +119,8 @@ export const useBlogWriterState = () => {
}
}
setOutlineTaskId(null);
// Reset outline confirmation when new outline is generated
setOutlineConfirmed(false);
}, [research]);
// Handle outline error
@@ -149,6 +154,36 @@ export const useBlogWriterState = () => {
localStorage.setItem('blog_selected_title', title);
}, [titleOptions]);
// Handle outline confirmation
const handleOutlineConfirmed = useCallback(() => {
setOutlineConfirmed(true);
console.log('Outline confirmed by user');
}, []);
// Handle outline refinement
const handleOutlineRefined = useCallback((feedback: string) => {
console.log('Outline refinement requested with feedback:', feedback);
// The actual refinement will be handled by the copilot action
}, []);
// Handle content updates from WYSIWYG editor
const handleContentUpdate = useCallback((updatedSections: any[]) => {
console.log('Content updated:', updatedSections);
// Update sections state with new content
const newSections: { [key: string]: string } = {};
updatedSections.forEach(section => {
newSections[section.id] = section.content;
});
setSections(newSections);
}, [setSections]);
// Handle content saving
const handleContentSave = useCallback((content: any) => {
console.log('Content saved:', content);
// Here you could save to backend or local storage
// For now, just log the content
}, []);
return {
// State
research,
@@ -167,6 +202,7 @@ export const useBlogWriterState = () => {
researchCoverage,
researchTitles,
aiGeneratedTitles,
outlineConfirmed,
// Setters
setResearch,
@@ -185,6 +221,7 @@ export const useBlogWriterState = () => {
setResearchCoverage,
setResearchTitles,
setAiGeneratedTitles,
setOutlineConfirmed,
// Handlers
handleResearchComplete,
@@ -193,6 +230,10 @@ export const useBlogWriterState = () => {
handleSectionGenerated,
handleContinuityRefresh,
handleTitleSelect,
handleCustomTitle
handleCustomTitle,
handleOutlineConfirmed,
handleOutlineRefined,
handleContentUpdate,
handleContentSave
};
};

View File

@@ -37,26 +37,37 @@ export function usePolling(
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
// Debug state changes
useEffect(() => {
console.log('Polling state changed:', { isPolling, currentStatus, progressCount: progressMessages.length });
}, [isPolling, currentStatus, progressMessages.length]);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const attemptsRef = useRef(0);
const currentTaskIdRef = useRef<string | null>(null);
const stopPolling = useCallback(() => {
console.log('stopPolling called');
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
console.log('Setting isPolling to false');
setIsPolling(false);
attemptsRef.current = 0;
currentTaskIdRef.current = null;
}, []);
const startPolling = useCallback((taskId: string) => {
console.log('startPolling called with taskId:', taskId);
if (isPolling) {
console.log('Already polling, stopping first');
stopPolling();
}
currentTaskIdRef.current = taskId;
console.log('Setting isPolling to true');
setIsPolling(true);
setCurrentStatus('pending');
setProgressMessages([]);
@@ -118,7 +129,7 @@ export function usePolling(
// Start polling immediately, then at intervals
poll();
intervalRef.current = setInterval(poll, interval);
}, [isPolling, interval, maxAttempts, onProgress, onComplete, onError, pollFunction, stopPolling, progressMessages.length]);
}, [isPolling, interval, onProgress, onComplete, onError, pollFunction, stopPolling, progressMessages.length]);
// Cleanup on unmount
useEffect(() => {
@@ -146,3 +157,12 @@ export function useResearchPolling(options: UsePollingOptions = {}) {
export function useOutlinePolling(options: UsePollingOptions = {}) {
return usePolling(blogWriterApi.pollOutlineStatus, options);
}
export function useMediumGenerationPolling(options: UsePollingOptions = {}) {
// Lazy import to avoid circular: poll function from mediumBlogApi
const pollFn = (taskId: string) => import('../services/blogWriterApi').then(m => m.mediumBlogApi.pollMediumGeneration(taskId));
// Wrap to satisfy type
const wrapped = (taskId: string) => pollFn(taskId) as unknown as Promise<TaskStatusResponse>;
// eslint-disable-next-line react-hooks/rules-of-hooks
return usePolling(wrapped, options);
}

View File

@@ -1,4 +1,4 @@
import { apiClient, aiApiClient, longRunningApiClient, pollingApiClient } from "../api/client";
import { apiClient, aiApiClient, pollingApiClient } from "../api/client";
export interface PersonaInfo {
persona_id?: string;
@@ -68,6 +68,7 @@ export interface BlogResearchResponse {
search_widget?: string;
search_queries?: string[];
grounding_metadata?: GroundingMetadata;
original_keywords?: string[]; // Original user-provided keywords for caching
error_message?: string;
}
@@ -283,4 +284,44 @@ export const blogWriterApi = {
}
};
// Medium blog generation (≤1000 words)
export interface MediumSectionOutlinePayload {
id: string;
heading: string;
keyPoints?: string[];
subheadings?: string[];
keywords?: string[];
targetWords?: number;
references?: ResearchSource[];
}
export interface MediumGenerationRequestPayload {
title: string;
sections: MediumSectionOutlinePayload[];
persona?: PersonaInfo;
tone?: string;
audience?: string;
globalTargetWords?: number;
researchKeywords?: string[]; // Original research keywords for better caching
}
export interface MediumGenerationResultPayload {
success: boolean;
title: string;
sections: Array<{ id: string; heading: string; content: string; wordCount: number; sources?: ResearchSource[] }>;
model?: string;
generation_time_ms?: number;
}
export const mediumBlogApi = {
async startMediumGeneration(payload: MediumGenerationRequestPayload): Promise<{ task_id: string; status: string }> {
const { data } = await aiApiClient.post('/api/blog/generate/medium/start', payload);
return data;
},
async pollMediumGeneration(taskId: string): Promise<TaskStatusResponse & { result?: MediumGenerationResultPayload }> {
const { data } = await pollingApiClient.get(`/api/blog/generate/medium/status/${taskId}`);
return data;
}
};