Allowing AI to generate suggestions for the blog writer
This commit is contained in:
@@ -31,6 +31,7 @@ from models.blog_models import (
|
|||||||
from services.blog_writer.blog_service import BlogWriterService
|
from services.blog_writer.blog_service import BlogWriterService
|
||||||
from .task_manager import task_manager
|
from .task_manager import task_manager
|
||||||
from .cache_manager import cache_manager
|
from .cache_manager import cache_manager
|
||||||
|
from models.blog_models import MediumBlogGenerateRequest
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
|
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
|
||||||
@@ -290,3 +291,39 @@ async def get_outline_cache_entries(limit: int = 20):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get outline cache entries: {e}")
|
logger.error(f"Failed to get outline cache entries: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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))
|
||||||
@@ -11,7 +11,12 @@ from datetime import datetime
|
|||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
from loguru import logger
|
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
|
from services.blog_writer.blog_service import BlogWriterService
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +112,12 @@ class TaskManager:
|
|||||||
|
|
||||||
return task_id
|
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):
|
async def _run_research_task(self, task_id: str, request: BlogResearchRequest):
|
||||||
"""Background task to run research and update status with progress messages."""
|
"""Background task to run research and update status with progress messages."""
|
||||||
try:
|
try:
|
||||||
@@ -174,6 +185,45 @@ class TaskManager:
|
|||||||
self.task_storage[task_id]["status"] = "failed"
|
self.task_storage[task_id]["status"] = "failed"
|
||||||
self.task_storage[task_id]["error"] = str(e)
|
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
|
# Global task manager instance
|
||||||
task_manager = TaskManager()
|
task_manager = TaskManager()
|
||||||
|
|||||||
@@ -215,3 +215,45 @@ class HallucinationCheckResponse(BaseModel):
|
|||||||
claims: List[Dict[str, Any]] = []
|
claims: List[Dict[str, Any]] = []
|
||||||
suggestions: 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
|
||||||
@@ -24,11 +24,19 @@ from models.blog_models import (
|
|||||||
BlogPublishRequest,
|
BlogPublishRequest,
|
||||||
BlogPublishResponse,
|
BlogPublishResponse,
|
||||||
BlogOutlineSection,
|
BlogOutlineSection,
|
||||||
|
ResearchSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..research import ResearchService
|
from ..research import ResearchService
|
||||||
from ..outline import OutlineService
|
from ..outline import OutlineService
|
||||||
from ..content.enhanced_content_generator import EnhancedContentGenerator
|
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:
|
class BlogWriterService:
|
||||||
@@ -258,3 +266,180 @@ class BlogWriterService:
|
|||||||
"""Publish content to specified platform."""
|
"""Publish content to specified platform."""
|
||||||
# TODO: Move to content module
|
# TODO: Move to content module
|
||||||
return BlogPublishResponse(success=True, platform=request.platform, url="https://example.com/post")
|
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
|
||||||
|
|||||||
363
backend/services/cache/persistent_content_cache.py
vendored
Normal file
363
backend/services/cache/persistent_content_cache.py
vendored
Normal 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()
|
||||||
@@ -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")
|
logger.info("No parsed content, trying to parse text response")
|
||||||
try:
|
try:
|
||||||
import json
|
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")
|
logger.info("Successfully parsed text as JSON")
|
||||||
return parsed_text
|
return parsed_text
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error(f"Failed to parse text as JSON: {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)
|
# Check candidates for content (fallback for edge cases)
|
||||||
if hasattr(response, 'candidates') and response.candidates:
|
if hasattr(response, 'candidates') and response.candidates:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||||
import '@copilotkit/react-ui/styles.css';
|
import '@copilotkit/react-ui/styles.css';
|
||||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||||
import { useOutlinePolling } from '../../hooks/usePolling';
|
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling } from '../../hooks/usePolling';
|
||||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||||
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
||||||
@@ -20,11 +20,12 @@ import { EnhancedOutlineActions } from './EnhancedOutlineActions';
|
|||||||
import HallucinationChecker from './HallucinationChecker';
|
import HallucinationChecker from './HallucinationChecker';
|
||||||
import Publisher from './Publisher';
|
import Publisher from './Publisher';
|
||||||
import OutlineGenerator from './OutlineGenerator';
|
import OutlineGenerator from './OutlineGenerator';
|
||||||
import SectionGenerator from './SectionGenerator';
|
|
||||||
import OutlineRefiner from './OutlineRefiner';
|
import OutlineRefiner from './OutlineRefiner';
|
||||||
import SEOProcessor from './SEOProcessor';
|
import SEOProcessor from './SEOProcessor';
|
||||||
import BlogWriterLanding from './BlogWriterLanding';
|
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 = () => {
|
export const BlogWriter: React.FC = () => {
|
||||||
// Use custom hook for all state management
|
// Use custom hook for all state management
|
||||||
@@ -45,6 +46,7 @@ export const BlogWriter: React.FC = () => {
|
|||||||
researchCoverage,
|
researchCoverage,
|
||||||
researchTitles,
|
researchTitles,
|
||||||
aiGeneratedTitles,
|
aiGeneratedTitles,
|
||||||
|
outlineConfirmed,
|
||||||
setOutline,
|
setOutline,
|
||||||
setTitleOptions,
|
setTitleOptions,
|
||||||
setSections,
|
setSections,
|
||||||
@@ -55,10 +57,12 @@ export const BlogWriter: React.FC = () => {
|
|||||||
handleResearchComplete,
|
handleResearchComplete,
|
||||||
handleOutlineComplete,
|
handleOutlineComplete,
|
||||||
handleOutlineError,
|
handleOutlineError,
|
||||||
handleSectionGenerated,
|
|
||||||
handleContinuityRefresh,
|
|
||||||
handleTitleSelect,
|
handleTitleSelect,
|
||||||
handleCustomTitle
|
handleCustomTitle,
|
||||||
|
handleOutlineConfirmed,
|
||||||
|
handleOutlineRefined,
|
||||||
|
handleContentUpdate,
|
||||||
|
handleContentSave
|
||||||
} = useBlogWriterState();
|
} = useBlogWriterState();
|
||||||
|
|
||||||
// Custom hooks for complex functionality
|
// Custom hooks for complex functionality
|
||||||
@@ -68,13 +72,16 @@ export const BlogWriter: React.FC = () => {
|
|||||||
setSections
|
setSections
|
||||||
);
|
);
|
||||||
|
|
||||||
const { convertMarkdownToHTML, getTotalWords, getOutlineStats } = useMarkdownProcessor(
|
const { convertMarkdownToHTML } = useMarkdownProcessor(
|
||||||
outline,
|
outline,
|
||||||
sections
|
sections
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get suggestions
|
// Research polling hook (for context awareness)
|
||||||
const suggestions = useSuggestions(research, outline);
|
const researchPolling = useResearchPolling({
|
||||||
|
onComplete: handleResearchComplete,
|
||||||
|
onError: (error) => console.error('Research polling error:', error)
|
||||||
|
});
|
||||||
|
|
||||||
// Outline polling hook
|
// Outline polling hook
|
||||||
const outlinePolling = useOutlinePolling({
|
const outlinePolling = useOutlinePolling({
|
||||||
@@ -82,22 +89,90 @@ export const BlogWriter: React.FC = () => {
|
|||||||
onError: handleOutlineError
|
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 (
|
return (
|
||||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<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 */}
|
{/* Extracted Components */}
|
||||||
<KeywordInputForm onResearchComplete={handleResearchComplete} />
|
<KeywordInputForm
|
||||||
|
onResearchComplete={handleResearchComplete}
|
||||||
|
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
|
||||||
|
/>
|
||||||
<CustomOutlineForm onOutlineCreated={setOutline} />
|
<CustomOutlineForm onOutlineCreated={setOutline} />
|
||||||
<ResearchAction onResearchComplete={handleResearchComplete} />
|
<ResearchAction onResearchComplete={handleResearchComplete} />
|
||||||
<ResearchDataActions
|
<ResearchDataActions
|
||||||
@@ -109,6 +184,14 @@ export const BlogWriter: React.FC = () => {
|
|||||||
outline={outline}
|
outline={outline}
|
||||||
onOutlineUpdated={setOutline}
|
onOutlineUpdated={setOutline}
|
||||||
/>
|
/>
|
||||||
|
<OutlineFeedbackForm
|
||||||
|
outline={outline}
|
||||||
|
research={research!}
|
||||||
|
onOutlineConfirmed={handleOutlineConfirmed}
|
||||||
|
onOutlineRefined={handleOutlineRefined}
|
||||||
|
onMediumGenerationStarted={handleMediumGenerationStarted}
|
||||||
|
onMediumGenerationTriggered={handleMediumGenerationTriggered}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* New extracted functionality components */}
|
{/* New extracted functionality components */}
|
||||||
<OutlineGenerator
|
<OutlineGenerator
|
||||||
@@ -116,13 +199,6 @@ export const BlogWriter: React.FC = () => {
|
|||||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||||
/>
|
/>
|
||||||
<SectionGenerator
|
|
||||||
outline={outline}
|
|
||||||
research={research}
|
|
||||||
genMode={genMode}
|
|
||||||
onSectionGenerated={handleSectionGenerated}
|
|
||||||
onContinuityRefresh={handleContinuityRefresh}
|
|
||||||
/>
|
|
||||||
<OutlineRefiner
|
<OutlineRefiner
|
||||||
outline={outline}
|
outline={outline}
|
||||||
onOutlineUpdated={setOutline}
|
onOutlineUpdated={setOutline}
|
||||||
@@ -161,57 +237,75 @@ export const BlogWriter: React.FC = () => {
|
|||||||
{research && outline.length === 0 && <ResearchResults research={research} />}
|
{research && outline.length === 0 && <ResearchResults research={research} />}
|
||||||
{outline.length > 0 && (
|
{outline.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{/* Enhanced Title Selection */}
|
{outlineConfirmed ? (
|
||||||
<EnhancedTitleSelector
|
/* WYSIWYG Editor - Show when outline is confirmed */
|
||||||
|
<BlogEditor
|
||||||
|
outline={outline}
|
||||||
|
research={research}
|
||||||
|
initialTitle={selectedTitle}
|
||||||
titleOptions={titleOptions}
|
titleOptions={titleOptions}
|
||||||
selectedTitle={selectedTitle}
|
researchTitles={researchTitles}
|
||||||
sections={outline}
|
aiGeneratedTitles={aiGeneratedTitles}
|
||||||
researchTitles={researchTitles}
|
sections={sections}
|
||||||
aiGeneratedTitles={aiGeneratedTitles}
|
onContentUpdate={handleContentUpdate}
|
||||||
onTitleSelect={handleTitleSelect}
|
onSave={handleContentSave}
|
||||||
onCustomTitle={handleCustomTitle}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
/* 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 */}
|
{/* Enhanced Outline Editor */}
|
||||||
<EnhancedOutlineEditor
|
<EnhancedOutlineEditor
|
||||||
outline={outline}
|
outline={outline}
|
||||||
research={research}
|
research={research}
|
||||||
sourceMappingStats={sourceMappingStats}
|
sourceMappingStats={sourceMappingStats}
|
||||||
groundingInsights={groundingInsights}
|
groundingInsights={groundingInsights}
|
||||||
optimizationResults={optimizationResults}
|
optimizationResults={optimizationResults}
|
||||||
researchCoverage={researchCoverage}
|
researchCoverage={researchCoverage}
|
||||||
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Draft/Polished Mode Toggle */}
|
{/* Draft/Polished Mode Toggle */}
|
||||||
<div style={{ margin: '12px 0' }}>
|
<div style={{ margin: '12px 0' }}>
|
||||||
<label style={{ marginRight: 8 }}>Generation mode:</label>
|
<label style={{ marginRight: 8 }}>Generation mode:</label>
|
||||||
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
|
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
|
||||||
<option value="draft">Draft (faster, lower cost)</option>
|
<option value="draft">Draft (faster, lower cost)</option>
|
||||||
<option value="polished">Polished (higher quality)</option>
|
<option value="polished">Polished (higher quality)</option>
|
||||||
</select>
|
</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} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{sections[s.id] ? (
|
|
||||||
<>
|
{outline.map(s => (
|
||||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
|
<div key={s.id} style={{ marginBottom: 16 }}>
|
||||||
<SEOMiniPanel analysis={seoAnalysis} />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
</>
|
<h4 style={{ margin: 0 }}>{s.heading}</h4>
|
||||||
) : (
|
{/* Continuity badge */}
|
||||||
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
|
{sections[s.id] && (
|
||||||
)}
|
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
|
||||||
</div>
|
)}
|
||||||
))}
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -231,6 +325,7 @@ export const BlogWriter: React.FC = () => {
|
|||||||
// Get current state information
|
// Get current state information
|
||||||
const hasResearch = research !== null;
|
const hasResearch = research !== null;
|
||||||
const hasOutline = outline.length > 0;
|
const hasOutline = outline.length > 0;
|
||||||
|
const isOutlineConfirmed = outlineConfirmed;
|
||||||
const researchInfo = hasResearch ? {
|
const researchInfo = hasResearch ? {
|
||||||
sources: research.sources?.length || 0,
|
sources: research.sources?.length || 0,
|
||||||
queries: research.search_queries?.length || 0,
|
queries: research.search_queries?.length || 0,
|
||||||
@@ -239,6 +334,14 @@ export const BlogWriter: React.FC = () => {
|
|||||||
searchIntent: research.keyword_analysis?.search_intent || 'informational'
|
searchIntent: research.keyword_analysis?.search_intent || 'informational'
|
||||||
} : null;
|
} : 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 = `
|
const toolGuide = `
|
||||||
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
|
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}
|
- Search intent: ${researchInfo.searchIntent}
|
||||||
` : '❌ No research completed yet'}
|
` : '❌ 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:
|
Available tools:
|
||||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
- 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
|
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
|
||||||
- generateOutline()
|
- generateOutline()
|
||||||
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
|
- 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)
|
- generateSection(sectionId: string)
|
||||||
- generateAllSections()
|
- 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
|
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
- 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 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
|
- 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 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:
|
ENGAGEMENT TACTICS:
|
||||||
- DO NOT ask for clarification - take action immediately with the information provided
|
- 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
|
- 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
|
- 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
|
- Use encouraging language and highlight progress made
|
||||||
- If user seems lost, remind them of the current stage and suggest the next step
|
- 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 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');
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const useCopilotActionTyped = useCopilotAction as any;
|
|||||||
interface KeywordInputFormProps {
|
interface KeywordInputFormProps {
|
||||||
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
|
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
|
||||||
onResearchComplete?: (researchData: BlogResearchResponse) => void;
|
onResearchComplete?: (researchData: BlogResearchResponse) => void;
|
||||||
|
onTaskStart?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate component to manage form state
|
// 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);
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keyword input action with Human-in-the-Loop
|
// Keyword input action with Human-in-the-Loop
|
||||||
@@ -214,9 +215,13 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
|
|||||||
word_count_target: parseInt(blogLength)
|
word_count_target: parseInt(blogLength)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store the blog length in localStorage for later use
|
||||||
|
localStorage.setItem('blog_length_target', blogLength);
|
||||||
|
|
||||||
// Start async research
|
// Start async research
|
||||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||||
setCurrentTaskId(task_id);
|
setCurrentTaskId(task_id);
|
||||||
|
onTaskStart?.(task_id); // Notify parent component to start polling
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
498
frontend/src/components/BlogWriter/OutlineFeedbackForm.tsx
Normal file
498
frontend/src/components/BlogWriter/OutlineFeedbackForm.tsx
Normal 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;
|
||||||
290
frontend/src/components/BlogWriter/OutlineProgressModal.tsx
Normal file
290
frontend/src/components/BlogWriter/OutlineProgressModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
108
frontend/src/components/BlogWriter/StyledSuggestions.tsx
Normal file
108
frontend/src/components/BlogWriter/StyledSuggestions.tsx
Normal 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;
|
||||||
@@ -4,19 +4,71 @@ import { BlogOutlineSection, BlogResearchResponse } from '../../services/blogWri
|
|||||||
interface SuggestionsGeneratorProps {
|
interface SuggestionsGeneratorProps {
|
||||||
research: BlogResearchResponse | null;
|
research: BlogResearchResponse | null;
|
||||||
outline: BlogOutlineSection[];
|
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(() => {
|
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) {
|
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) {
|
} else if (research && outline.length === 0) {
|
||||||
// Research completed, guide user to outline creation
|
// Research completed, guide user to outline creation
|
||||||
items.push({
|
items.push({
|
||||||
title: '🧩 Create Outline',
|
title: 'Next: Create Outline',
|
||||||
message: 'Let\'s proceed to create an outline based on the research results'
|
message: 'Let\'s proceed to create an outline based on the research results',
|
||||||
|
priority: 'high'
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
title: '💬 Chat with Research Data',
|
title: '💬 Chat with Research Data',
|
||||||
@@ -26,13 +78,29 @@ export const useSuggestions = (research: BlogResearchResponse | null, outline: B
|
|||||||
title: '🎨 Create Custom Outline',
|
title: '🎨 Create Custom Outline',
|
||||||
message: 'I want to create an outline with my own specific instructions and requirements'
|
message: 'I want to create an outline with my own specific instructions and requirements'
|
||||||
});
|
});
|
||||||
} else if (outline.length > 0) {
|
} else if (outline.length > 0 && !outlineConfirmed) {
|
||||||
// Outline created, focus on content generation
|
// 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' });
|
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}` }));
|
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: '📈 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: '🧾 Generate SEO metadata', message: 'Generate SEO metadata and title' });
|
||||||
items.push({ title: '🧪 Hallucination check', message: 'Check for any false claims in my content' });
|
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;
|
return items;
|
||||||
}, [research, outline]);
|
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline }) => {
|
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
|
||||||
const suggestions = useSuggestions(research, outline);
|
useSuggestions(research, outline, outlineConfirmed);
|
||||||
return null; // This is just a utility component
|
return null; // This is just a utility component
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
293
frontend/src/components/BlogWriter/WYSIWYG/BlogEditor.tsx
Normal file
293
frontend/src/components/BlogWriter/WYSIWYG/BlogEditor.tsx
Normal 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;
|
||||||
373
frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx
Normal file
373
frontend/src/components/BlogWriter/WYSIWYG/BlogSection.tsx
Normal 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;
|
||||||
@@ -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 };
|
||||||
89
frontend/src/components/BlogWriter/WYSIWYG/EditorSidebar.tsx
Normal file
89
frontend/src/components/BlogWriter/WYSIWYG/EditorSidebar.tsx
Normal 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> • <span>{totalWords} words total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditorSidebar;
|
||||||
318
frontend/src/components/BlogWriter/WYSIWYG/HoverMenu.tsx
Normal file
318
frontend/src/components/BlogWriter/WYSIWYG/HoverMenu.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
3
frontend/src/components/BlogWriter/WYSIWYG/index.ts
Normal file
3
frontend/src/components/BlogWriter/WYSIWYG/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as BlogEditor } from './BlogEditor';
|
||||||
|
export { default as HoverMenu } from './HoverMenu';
|
||||||
|
export { default as ResearchIntegration } from './ResearchIntegration';
|
||||||
@@ -25,6 +25,9 @@ export const useBlogWriterState = () => {
|
|||||||
const [researchTitles, setResearchTitles] = useState<string[]>([]);
|
const [researchTitles, setResearchTitles] = useState<string[]>([]);
|
||||||
const [aiGeneratedTitles, setAiGeneratedTitles] = 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
|
// Cache recovery - restore most recent research on page load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cachedEntries = researchCache.getAllCachedEntries();
|
const cachedEntries = researchCache.getAllCachedEntries();
|
||||||
@@ -116,6 +119,8 @@ export const useBlogWriterState = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setOutlineTaskId(null);
|
setOutlineTaskId(null);
|
||||||
|
// Reset outline confirmation when new outline is generated
|
||||||
|
setOutlineConfirmed(false);
|
||||||
}, [research]);
|
}, [research]);
|
||||||
|
|
||||||
// Handle outline error
|
// Handle outline error
|
||||||
@@ -149,6 +154,36 @@ export const useBlogWriterState = () => {
|
|||||||
localStorage.setItem('blog_selected_title', title);
|
localStorage.setItem('blog_selected_title', title);
|
||||||
}, [titleOptions]);
|
}, [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 {
|
return {
|
||||||
// State
|
// State
|
||||||
research,
|
research,
|
||||||
@@ -167,6 +202,7 @@ export const useBlogWriterState = () => {
|
|||||||
researchCoverage,
|
researchCoverage,
|
||||||
researchTitles,
|
researchTitles,
|
||||||
aiGeneratedTitles,
|
aiGeneratedTitles,
|
||||||
|
outlineConfirmed,
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
setResearch,
|
setResearch,
|
||||||
@@ -185,6 +221,7 @@ export const useBlogWriterState = () => {
|
|||||||
setResearchCoverage,
|
setResearchCoverage,
|
||||||
setResearchTitles,
|
setResearchTitles,
|
||||||
setAiGeneratedTitles,
|
setAiGeneratedTitles,
|
||||||
|
setOutlineConfirmed,
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
handleResearchComplete,
|
handleResearchComplete,
|
||||||
@@ -193,6 +230,10 @@ export const useBlogWriterState = () => {
|
|||||||
handleSectionGenerated,
|
handleSectionGenerated,
|
||||||
handleContinuityRefresh,
|
handleContinuityRefresh,
|
||||||
handleTitleSelect,
|
handleTitleSelect,
|
||||||
handleCustomTitle
|
handleCustomTitle,
|
||||||
|
handleOutlineConfirmed,
|
||||||
|
handleOutlineRefined,
|
||||||
|
handleContentUpdate,
|
||||||
|
handleContentSave
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,26 +37,37 @@ export function usePolling(
|
|||||||
const [result, setResult] = useState<any>(null);
|
const [result, setResult] = useState<any>(null);
|
||||||
const [error, setError] = useState<string | null>(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 intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const attemptsRef = useRef(0);
|
const attemptsRef = useRef(0);
|
||||||
const currentTaskIdRef = useRef<string | null>(null);
|
const currentTaskIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const stopPolling = useCallback(() => {
|
const stopPolling = useCallback(() => {
|
||||||
|
console.log('stopPolling called');
|
||||||
if (intervalRef.current) {
|
if (intervalRef.current) {
|
||||||
clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
|
console.log('Setting isPolling to false');
|
||||||
setIsPolling(false);
|
setIsPolling(false);
|
||||||
attemptsRef.current = 0;
|
attemptsRef.current = 0;
|
||||||
currentTaskIdRef.current = null;
|
currentTaskIdRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startPolling = useCallback((taskId: string) => {
|
const startPolling = useCallback((taskId: string) => {
|
||||||
|
console.log('startPolling called with taskId:', taskId);
|
||||||
if (isPolling) {
|
if (isPolling) {
|
||||||
|
console.log('Already polling, stopping first');
|
||||||
stopPolling();
|
stopPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
currentTaskIdRef.current = taskId;
|
currentTaskIdRef.current = taskId;
|
||||||
|
console.log('Setting isPolling to true');
|
||||||
setIsPolling(true);
|
setIsPolling(true);
|
||||||
setCurrentStatus('pending');
|
setCurrentStatus('pending');
|
||||||
setProgressMessages([]);
|
setProgressMessages([]);
|
||||||
@@ -118,7 +129,7 @@ export function usePolling(
|
|||||||
// Start polling immediately, then at intervals
|
// Start polling immediately, then at intervals
|
||||||
poll();
|
poll();
|
||||||
intervalRef.current = setInterval(poll, interval);
|
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
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -146,3 +157,12 @@ export function useResearchPolling(options: UsePollingOptions = {}) {
|
|||||||
export function useOutlinePolling(options: UsePollingOptions = {}) {
|
export function useOutlinePolling(options: UsePollingOptions = {}) {
|
||||||
return usePolling(blogWriterApi.pollOutlineStatus, options);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiClient, aiApiClient, longRunningApiClient, pollingApiClient } from "../api/client";
|
import { apiClient, aiApiClient, pollingApiClient } from "../api/client";
|
||||||
|
|
||||||
export interface PersonaInfo {
|
export interface PersonaInfo {
|
||||||
persona_id?: string;
|
persona_id?: string;
|
||||||
@@ -68,6 +68,7 @@ export interface BlogResearchResponse {
|
|||||||
search_widget?: string;
|
search_widget?: string;
|
||||||
search_queries?: string[];
|
search_queries?: string[];
|
||||||
grounding_metadata?: GroundingMetadata;
|
grounding_metadata?: GroundingMetadata;
|
||||||
|
original_keywords?: string[]; // Original user-provided keywords for caching
|
||||||
error_message?: string;
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user