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 .task_manager import task_manager
|
||||
from .cache_manager import cache_manager
|
||||
from models.blog_models import MediumBlogGenerateRequest
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
|
||||
@@ -289,4 +290,40 @@ async def get_outline_cache_entries(limit: int = 20):
|
||||
return cache_manager.get_recent_outline_cache_entries(limit)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get outline cache entries: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Medium Blog Generation API
|
||||
# ---------------------------
|
||||
|
||||
@router.post("/generate/medium/start")
|
||||
async def start_medium_generation(request: MediumBlogGenerateRequest):
|
||||
"""Start medium-length blog generation (≤1000 words) and return a task id."""
|
||||
try:
|
||||
# Simple server-side guard
|
||||
if (request.globalTargetWords or 1000) > 1000:
|
||||
raise HTTPException(status_code=400, detail="Global target words exceed 1000; use per-section generation")
|
||||
|
||||
task_id = task_manager.start_medium_generation_task(request)
|
||||
return {"task_id": task_id, "status": "started"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start medium generation: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/generate/medium/status/{task_id}")
|
||||
async def medium_generation_status(task_id: str):
|
||||
"""Poll status for medium blog generation task."""
|
||||
try:
|
||||
status = task_manager.get_task_status(task_id)
|
||||
if status is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return status
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get medium generation status for {task_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -11,7 +11,12 @@ from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
from loguru import logger
|
||||
|
||||
from models.blog_models import BlogResearchRequest, BlogOutlineRequest
|
||||
from models.blog_models import (
|
||||
BlogResearchRequest,
|
||||
BlogOutlineRequest,
|
||||
MediumBlogGenerateRequest,
|
||||
MediumBlogGenerateResult,
|
||||
)
|
||||
from services.blog_writer.blog_service import BlogWriterService
|
||||
|
||||
|
||||
@@ -106,6 +111,12 @@ class TaskManager:
|
||||
asyncio.create_task(self._run_outline_generation_task(task_id, request))
|
||||
|
||||
return task_id
|
||||
|
||||
def start_medium_generation_task(self, request: MediumBlogGenerateRequest) -> str:
|
||||
"""Start a medium (≤1000 words) full-blog generation task."""
|
||||
task_id = self.create_task("medium_generation")
|
||||
asyncio.create_task(self._run_medium_generation_task(task_id, request))
|
||||
return task_id
|
||||
|
||||
async def _run_research_task(self, task_id: str, request: BlogResearchRequest):
|
||||
"""Background task to run research and update status with progress messages."""
|
||||
@@ -174,6 +185,45 @@ class TaskManager:
|
||||
self.task_storage[task_id]["status"] = "failed"
|
||||
self.task_storage[task_id]["error"] = str(e)
|
||||
|
||||
async def _run_medium_generation_task(self, task_id: str, request: MediumBlogGenerateRequest):
|
||||
"""Background task to generate a medium blog using a single structured JSON call."""
|
||||
try:
|
||||
self.task_storage[task_id]["status"] = "running"
|
||||
self.task_storage[task_id]["progress_messages"] = []
|
||||
|
||||
await self.update_progress(task_id, "📦 Packaging outline and metadata...")
|
||||
|
||||
# Basic guard: respect global target words
|
||||
total_target = int(request.globalTargetWords or 1000)
|
||||
if total_target > 1000:
|
||||
raise ValueError("Global target words exceed 1000; medium generation not allowed")
|
||||
|
||||
result: MediumBlogGenerateResult = await self.service.generate_medium_blog_with_progress(
|
||||
request,
|
||||
task_id,
|
||||
)
|
||||
|
||||
if not result or not getattr(result, "sections", None):
|
||||
raise ValueError("Empty generation result from model")
|
||||
|
||||
# Check if result came from cache
|
||||
cache_hit = getattr(result, 'cache_hit', False)
|
||||
if cache_hit:
|
||||
await self.update_progress(task_id, "⚡ Found cached content - loading instantly!")
|
||||
else:
|
||||
await self.update_progress(task_id, "🤖 Generated fresh content with AI...")
|
||||
await self.update_progress(task_id, "✨ Post-processing and assembling sections...")
|
||||
|
||||
# Mark completed
|
||||
self.task_storage[task_id]["status"] = "completed"
|
||||
self.task_storage[task_id]["result"] = result.dict()
|
||||
await self.update_progress(task_id, f"✅ Generated {len(result.sections)} sections successfully.")
|
||||
|
||||
except Exception as e:
|
||||
await self.update_progress(task_id, f"❌ Medium generation failed: {str(e)}")
|
||||
self.task_storage[task_id]["status"] = "failed"
|
||||
self.task_storage[task_id]["error"] = str(e)
|
||||
|
||||
|
||||
# Global task manager instance
|
||||
task_manager = TaskManager()
|
||||
|
||||
@@ -215,3 +215,45 @@ class HallucinationCheckResponse(BaseModel):
|
||||
claims: List[Dict[str, Any]] = []
|
||||
suggestions: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
# -----------------------
|
||||
# Medium Blog Generation
|
||||
# -----------------------
|
||||
|
||||
class MediumSectionOutline(BaseModel):
|
||||
"""Lightweight outline payload for medium blog generation."""
|
||||
id: str
|
||||
heading: str
|
||||
keyPoints: List[str] = []
|
||||
subheadings: List[str] = []
|
||||
keywords: List[str] = []
|
||||
targetWords: Optional[int] = None
|
||||
references: List[ResearchSource] = []
|
||||
|
||||
|
||||
class MediumBlogGenerateRequest(BaseModel):
|
||||
"""Request to generate an entire medium-length blog in one pass."""
|
||||
title: str
|
||||
sections: List[MediumSectionOutline]
|
||||
persona: Optional[PersonaInfo] = None
|
||||
tone: Optional[str] = None
|
||||
audience: Optional[str] = None
|
||||
globalTargetWords: Optional[int] = 1000
|
||||
researchKeywords: Optional[List[str]] = None # Original research keywords for better caching
|
||||
|
||||
|
||||
class MediumGeneratedSection(BaseModel):
|
||||
id: str
|
||||
heading: str
|
||||
content: str
|
||||
wordCount: int
|
||||
sources: Optional[List[ResearchSource]] = None
|
||||
|
||||
|
||||
class MediumBlogGenerateResult(BaseModel):
|
||||
success: bool = True
|
||||
title: str
|
||||
sections: List[MediumGeneratedSection]
|
||||
model: Optional[str] = None
|
||||
generation_time_ms: Optional[int] = None
|
||||
safety_flags: Optional[Dict[str, Any]] = None
|
||||
@@ -24,11 +24,19 @@ from models.blog_models import (
|
||||
BlogPublishRequest,
|
||||
BlogPublishResponse,
|
||||
BlogOutlineSection,
|
||||
ResearchSource,
|
||||
)
|
||||
|
||||
from ..research import ResearchService
|
||||
from ..outline import OutlineService
|
||||
from ..content.enhanced_content_generator import EnhancedContentGenerator
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.cache.persistent_content_cache import persistent_content_cache
|
||||
from models.blog_models import (
|
||||
MediumBlogGenerateRequest,
|
||||
MediumBlogGenerateResult,
|
||||
MediumGeneratedSection,
|
||||
)
|
||||
|
||||
|
||||
class BlogWriterService:
|
||||
@@ -258,3 +266,180 @@ class BlogWriterService:
|
||||
"""Publish content to specified platform."""
|
||||
# TODO: Move to content module
|
||||
return BlogPublishResponse(success=True, platform=request.platform, url="https://example.com/post")
|
||||
|
||||
async def generate_medium_blog_with_progress(self, req: MediumBlogGenerateRequest, task_id: str) -> MediumBlogGenerateResult:
|
||||
"""Use Gemini structured JSON to generate a medium-length blog in one call."""
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
# Prepare sections data for cache key generation
|
||||
sections_for_cache = []
|
||||
for s in req.sections:
|
||||
sections_for_cache.append({
|
||||
"id": s.id,
|
||||
"heading": s.heading,
|
||||
"keyPoints": getattr(s, "key_points", []) or getattr(s, "keyPoints", []),
|
||||
"subheadings": getattr(s, "subheadings", []),
|
||||
"keywords": getattr(s, "keywords", []),
|
||||
"targetWords": getattr(s, "target_words", None) or getattr(s, "targetWords", None),
|
||||
})
|
||||
|
||||
# Check cache first
|
||||
cached_result = persistent_content_cache.get_cached_content(
|
||||
keywords=req.researchKeywords or [],
|
||||
sections=sections_for_cache,
|
||||
global_target_words=req.globalTargetWords or 1000,
|
||||
persona_data=req.persona.dict() if req.persona else None,
|
||||
tone=req.tone,
|
||||
audience=req.audience
|
||||
)
|
||||
|
||||
if cached_result:
|
||||
logger.info(f"Using cached content for keywords: {req.researchKeywords} (saved expensive generation)")
|
||||
# Add cache hit marker to distinguish from fresh generation
|
||||
cached_result['generation_time_ms'] = 0 # Mark as cache hit
|
||||
cached_result['cache_hit'] = True
|
||||
return MediumBlogGenerateResult(**cached_result)
|
||||
|
||||
# Cache miss - proceed with AI generation
|
||||
logger.info(f"Cache miss - generating new content for keywords: {req.researchKeywords}")
|
||||
|
||||
# Build schema expected from the model
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"heading": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"wordCount": {"type": "number"},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}, "url": {"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Compose prompt
|
||||
def section_block(s):
|
||||
return {
|
||||
"id": s.id,
|
||||
"heading": s.heading,
|
||||
"outline": {
|
||||
"keyPoints": getattr(s, "key_points", []) or getattr(s, "keyPoints", []),
|
||||
"subheadings": getattr(s, "subheadings", []),
|
||||
"keywords": getattr(s, "keywords", []),
|
||||
"targetWords": getattr(s, "target_words", None) or getattr(s, "targetWords", None),
|
||||
"references": [
|
||||
{"title": r.title, "url": r.url} for r in getattr(s, "references", [])
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
payload = {
|
||||
"title": req.title,
|
||||
"globalTargetWords": req.globalTargetWords or 1000,
|
||||
"persona": req.persona.dict() if req.persona else None,
|
||||
"tone": req.tone,
|
||||
"audience": req.audience,
|
||||
"sections": [section_block(s) for s in req.sections],
|
||||
}
|
||||
|
||||
system = (
|
||||
"You are a professional blog writer. Generate high-quality content for each section based on the provided outline. "
|
||||
"Write engaging, informative content that follows the section's key points and target word count. "
|
||||
"Use a professional tone and ensure the content flows naturally. "
|
||||
"Format content with proper paragraph breaks using double line breaks (\\n\\n) between paragraphs. "
|
||||
"Structure content with clear paragraphs - aim for 2-4 sentences per paragraph. "
|
||||
"Return ONLY valid JSON with no markdown formatting or explanations."
|
||||
)
|
||||
|
||||
import json
|
||||
prompt = (
|
||||
f"Write blog content for the following sections. Each section should be {req.globalTargetWords or 1000} words total, distributed across all sections.\n\n"
|
||||
f"Blog Title: {req.title}\n\n"
|
||||
"For each section, write engaging content that:\n"
|
||||
"- Follows the key points provided\n"
|
||||
"- Uses the suggested keywords naturally\n"
|
||||
"- Meets the target word count\n"
|
||||
"- Maintains professional tone\n"
|
||||
"- References the provided sources when relevant\n"
|
||||
"- Breaks content into clear paragraphs (2-4 sentences each)\n"
|
||||
"- Uses double line breaks (\\n\\n) between paragraphs for proper formatting\n"
|
||||
"- Starts with an engaging opening paragraph\n"
|
||||
"- Ends with a strong concluding paragraph\n\n"
|
||||
"IMPORTANT: Format the 'content' field with proper paragraph breaks using \\n\\n between paragraphs.\n\n"
|
||||
"Return a JSON object with 'title' and 'sections' array. Each section should have 'id', 'heading', 'content', and 'wordCount'.\n\n"
|
||||
f"Sections to write:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
|
||||
ai_resp = gemini_structured_json_response(
|
||||
prompt=prompt,
|
||||
schema=schema,
|
||||
temperature=0.2,
|
||||
max_tokens=8192,
|
||||
system_prompt=system,
|
||||
)
|
||||
|
||||
# Check for errors in AI response
|
||||
if not ai_resp or ai_resp.get("error"):
|
||||
error_msg = ai_resp.get("error", "Empty generation result from model") if ai_resp else "No response from model"
|
||||
logger.error(f"AI generation failed: {error_msg}")
|
||||
raise Exception(f"AI generation failed: {error_msg}")
|
||||
|
||||
# Normalize output
|
||||
title = ai_resp.get("title") or req.title
|
||||
out_sections = []
|
||||
for s in ai_resp.get("sections", []) or []:
|
||||
out_sections.append(
|
||||
MediumGeneratedSection(
|
||||
id=str(s.get("id")),
|
||||
heading=s.get("heading") or "",
|
||||
content=s.get("content") or "",
|
||||
wordCount=int(s.get("wordCount") or 0),
|
||||
sources=[
|
||||
# map to ResearchSource shape if possible; keep minimal
|
||||
ResearchSource(title=src.get("title", ""), url=src.get("url", ""))
|
||||
for src in (s.get("sources") or [])
|
||||
] or None,
|
||||
)
|
||||
)
|
||||
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
result = MediumBlogGenerateResult(
|
||||
success=True,
|
||||
title=title,
|
||||
sections=out_sections,
|
||||
model="gemini-2.5-flash",
|
||||
generation_time_ms=duration_ms,
|
||||
safety_flags=None,
|
||||
)
|
||||
|
||||
# Cache the result for future use
|
||||
try:
|
||||
persistent_content_cache.cache_content(
|
||||
keywords=req.researchKeywords or [],
|
||||
sections=sections_for_cache,
|
||||
global_target_words=req.globalTargetWords or 1000,
|
||||
persona_data=req.persona.dict() if req.persona else None,
|
||||
tone=req.tone or "professional",
|
||||
audience=req.audience or "general",
|
||||
result=result.dict()
|
||||
)
|
||||
logger.info(f"Cached content result for keywords: {req.researchKeywords}")
|
||||
except Exception as cache_error:
|
||||
logger.warning(f"Failed to cache content result: {cache_error}")
|
||||
# Don't fail the entire operation if caching fails
|
||||
|
||||
return result
|
||||
|
||||
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")
|
||||
try:
|
||||
import json
|
||||
parsed_text = json.loads(response.text)
|
||||
import re
|
||||
|
||||
# Clean the text response to fix common JSON issues
|
||||
cleaned_text = response.text.strip()
|
||||
|
||||
# Remove any markdown code blocks if present
|
||||
if cleaned_text.startswith('```json'):
|
||||
cleaned_text = cleaned_text[7:]
|
||||
if cleaned_text.endswith('```'):
|
||||
cleaned_text = cleaned_text[:-3]
|
||||
cleaned_text = cleaned_text.strip()
|
||||
|
||||
# Try to find JSON content between curly braces
|
||||
json_match = re.search(r'\{.*\}', cleaned_text, re.DOTALL)
|
||||
if json_match:
|
||||
cleaned_text = json_match.group(0)
|
||||
|
||||
parsed_text = json.loads(cleaned_text)
|
||||
logger.info("Successfully parsed text as JSON")
|
||||
return parsed_text
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse text as JSON: {e}")
|
||||
logger.debug(f"Problematic text (first 500 chars): {response.text[:500]}")
|
||||
|
||||
# Try to extract and fix JSON manually
|
||||
try:
|
||||
import re
|
||||
# Look for the main JSON object
|
||||
json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
|
||||
matches = re.findall(json_pattern, response.text, re.DOTALL)
|
||||
if matches:
|
||||
# Try the largest match (likely the main JSON)
|
||||
largest_match = max(matches, key=len)
|
||||
# Basic cleanup of common issues
|
||||
fixed_json = largest_match.replace('\n', ' ').replace('\r', ' ')
|
||||
# Remove any trailing commas before closing braces
|
||||
fixed_json = re.sub(r',\s*}', '}', fixed_json)
|
||||
fixed_json = re.sub(r',\s*]', ']', fixed_json)
|
||||
|
||||
parsed_text = json.loads(fixed_json)
|
||||
logger.info("Successfully parsed cleaned JSON")
|
||||
return parsed_text
|
||||
except Exception as fix_error:
|
||||
logger.error(f"Failed to fix JSON manually: {fix_error}")
|
||||
|
||||
# Check candidates for content (fallback for edge cases)
|
||||
if hasattr(response, 'candidates') and response.candidates:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { useOutlinePolling } from '../../hooks/usePolling';
|
||||
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling } from '../../hooks/usePolling';
|
||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
||||
@@ -20,11 +20,12 @@ import { EnhancedOutlineActions } from './EnhancedOutlineActions';
|
||||
import HallucinationChecker from './HallucinationChecker';
|
||||
import Publisher from './Publisher';
|
||||
import OutlineGenerator from './OutlineGenerator';
|
||||
import SectionGenerator from './SectionGenerator';
|
||||
import OutlineRefiner from './OutlineRefiner';
|
||||
import SEOProcessor from './SEOProcessor';
|
||||
import BlogWriterLanding from './BlogWriterLanding';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { OutlineProgressModal } from './OutlineProgressModal';
|
||||
import OutlineFeedbackForm from './OutlineFeedbackForm';
|
||||
import { BlogEditor } from './WYSIWYG';
|
||||
|
||||
export const BlogWriter: React.FC = () => {
|
||||
// Use custom hook for all state management
|
||||
@@ -45,6 +46,7 @@ export const BlogWriter: React.FC = () => {
|
||||
researchCoverage,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
outlineConfirmed,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
setSections,
|
||||
@@ -55,10 +57,12 @@ export const BlogWriter: React.FC = () => {
|
||||
handleResearchComplete,
|
||||
handleOutlineComplete,
|
||||
handleOutlineError,
|
||||
handleSectionGenerated,
|
||||
handleContinuityRefresh,
|
||||
handleTitleSelect,
|
||||
handleCustomTitle
|
||||
handleCustomTitle,
|
||||
handleOutlineConfirmed,
|
||||
handleOutlineRefined,
|
||||
handleContentUpdate,
|
||||
handleContentSave
|
||||
} = useBlogWriterState();
|
||||
|
||||
// Custom hooks for complex functionality
|
||||
@@ -68,13 +72,16 @@ export const BlogWriter: React.FC = () => {
|
||||
setSections
|
||||
);
|
||||
|
||||
const { convertMarkdownToHTML, getTotalWords, getOutlineStats } = useMarkdownProcessor(
|
||||
const { convertMarkdownToHTML } = useMarkdownProcessor(
|
||||
outline,
|
||||
sections
|
||||
);
|
||||
|
||||
// Get suggestions
|
||||
const suggestions = useSuggestions(research, outline);
|
||||
// Research polling hook (for context awareness)
|
||||
const researchPolling = useResearchPolling({
|
||||
onComplete: handleResearchComplete,
|
||||
onError: (error) => console.error('Research polling error:', error)
|
||||
});
|
||||
|
||||
// Outline polling hook
|
||||
const outlinePolling = useOutlinePolling({
|
||||
@@ -82,22 +89,90 @@ export const BlogWriter: React.FC = () => {
|
||||
onError: handleOutlineError
|
||||
});
|
||||
|
||||
// Medium generation polling (used after confirm if short blog)
|
||||
const mediumPolling = useMediumGenerationPolling({
|
||||
onComplete: (result: any) => {
|
||||
try {
|
||||
if (result && result.sections) {
|
||||
const newSections: Record<string, string> = {};
|
||||
result.sections.forEach((s: any) => {
|
||||
newSections[String(s.id)] = s.content || '';
|
||||
});
|
||||
setSections(newSections);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply medium generation result:', e);
|
||||
}
|
||||
},
|
||||
onError: (err) => console.error('Medium generation failed:', err)
|
||||
});
|
||||
|
||||
// Get context-aware suggestions based on current task status
|
||||
const suggestions = useSuggestions(
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
{ isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
|
||||
{ isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
|
||||
{ isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus }
|
||||
);
|
||||
|
||||
// Add minimum display time for modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
|
||||
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if ((mediumPolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
||||
setShowModal(true);
|
||||
setModalStartTime(Date.now());
|
||||
} else if (!mediumPolling.isPolling && !isMediumGenerationStarting && showModal) {
|
||||
const elapsed = Date.now() - (modalStartTime || 0);
|
||||
const minDisplayTime = 2000; // 2 seconds minimum
|
||||
|
||||
if (elapsed < minDisplayTime) {
|
||||
setTimeout(() => {
|
||||
setShowModal(false);
|
||||
setModalStartTime(null);
|
||||
}, minDisplayTime - elapsed);
|
||||
} else {
|
||||
setShowModal(false);
|
||||
setModalStartTime(null);
|
||||
}
|
||||
}
|
||||
}, [mediumPolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
|
||||
|
||||
// Handle medium generation start from OutlineFeedbackForm
|
||||
const handleMediumGenerationStarted = (taskId: string) => {
|
||||
console.log('Starting medium generation polling for task:', taskId);
|
||||
setIsMediumGenerationStarting(false); // Clear the starting state
|
||||
mediumPolling.startPolling(taskId);
|
||||
};
|
||||
|
||||
// Show modal immediately when copilot action is triggered
|
||||
const handleMediumGenerationTriggered = () => {
|
||||
console.log('Medium generation triggered - showing modal immediately');
|
||||
setIsMediumGenerationStarting(true);
|
||||
};
|
||||
|
||||
// Debug medium polling state
|
||||
console.log('Medium polling state:', {
|
||||
isPolling: mediumPolling.isPolling,
|
||||
status: mediumPolling.currentStatus,
|
||||
progressCount: mediumPolling.progressMessages.length
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Outline Progress Modal */}
|
||||
<ResearchProgressModal
|
||||
open={Boolean(outlineTaskId && (outlinePolling.isPolling || outlinePolling.currentStatus === 'pending' || outlinePolling.currentStatus === 'running'))}
|
||||
title="Outline generation in progress"
|
||||
status={outlinePolling.currentStatus}
|
||||
messages={outlinePolling.progressMessages}
|
||||
error={outlinePolling.error}
|
||||
onClose={() => { /* informational while processing */ }}
|
||||
/>
|
||||
{/* Extracted Components */}
|
||||
<KeywordInputForm onResearchComplete={handleResearchComplete} />
|
||||
<KeywordInputForm
|
||||
onResearchComplete={handleResearchComplete}
|
||||
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
|
||||
/>
|
||||
<CustomOutlineForm onOutlineCreated={setOutline} />
|
||||
<ResearchAction onResearchComplete={handleResearchComplete} />
|
||||
<ResearchDataActions
|
||||
@@ -109,6 +184,14 @@ export const BlogWriter: React.FC = () => {
|
||||
outline={outline}
|
||||
onOutlineUpdated={setOutline}
|
||||
/>
|
||||
<OutlineFeedbackForm
|
||||
outline={outline}
|
||||
research={research!}
|
||||
onOutlineConfirmed={handleOutlineConfirmed}
|
||||
onOutlineRefined={handleOutlineRefined}
|
||||
onMediumGenerationStarted={handleMediumGenerationStarted}
|
||||
onMediumGenerationTriggered={handleMediumGenerationTriggered}
|
||||
/>
|
||||
|
||||
{/* New extracted functionality components */}
|
||||
<OutlineGenerator
|
||||
@@ -116,13 +199,6 @@ export const BlogWriter: React.FC = () => {
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
/>
|
||||
<SectionGenerator
|
||||
outline={outline}
|
||||
research={research}
|
||||
genMode={genMode}
|
||||
onSectionGenerated={handleSectionGenerated}
|
||||
onContinuityRefresh={handleContinuityRefresh}
|
||||
/>
|
||||
<OutlineRefiner
|
||||
outline={outline}
|
||||
onOutlineUpdated={setOutline}
|
||||
@@ -161,57 +237,75 @@ export const BlogWriter: React.FC = () => {
|
||||
{research && outline.length === 0 && <ResearchResults research={research} />}
|
||||
{outline.length > 0 && (
|
||||
<div>
|
||||
{/* Enhanced Title Selection */}
|
||||
<EnhancedTitleSelector
|
||||
{outlineConfirmed ? (
|
||||
/* WYSIWYG Editor - Show when outline is confirmed */
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle}
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
sections={outline}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
/>
|
||||
|
||||
) : (
|
||||
/* Outline Editor - Show when outline is not confirmed */
|
||||
<>
|
||||
{/* Enhanced Title Selection */}
|
||||
<EnhancedTitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
sections={outline}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
/>
|
||||
|
||||
|
||||
{/* Enhanced Outline Editor */}
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
/>
|
||||
{/* Enhanced Outline Editor */}
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op, id, payload) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
/>
|
||||
|
||||
{/* Draft/Polished Mode Toggle */}
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label style={{ marginRight: 8 }}>Generation mode:</label>
|
||||
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
|
||||
<option value="draft">Draft (faster, lower cost)</option>
|
||||
<option value="polished">Polished (higher quality)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{outline.map(s => (
|
||||
<div key={s.id} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h4 style={{ margin: 0 }}>{s.heading}</h4>
|
||||
{/* Continuity badge */}
|
||||
{sections[s.id] && (
|
||||
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
|
||||
)}
|
||||
{/* Draft/Polished Mode Toggle */}
|
||||
<div style={{ margin: '12px 0' }}>
|
||||
<label style={{ marginRight: 8 }}>Generation mode:</label>
|
||||
<select value={genMode} onChange={(e) => setGenMode(e.target.value as 'draft' | 'polished')}>
|
||||
<option value="draft">Draft (faster, lower cost)</option>
|
||||
<option value="polished">Polished (higher quality)</option>
|
||||
</select>
|
||||
</div>
|
||||
{sections[s.id] ? (
|
||||
<>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
|
||||
<SEOMiniPanel analysis={seoAnalysis} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{outline.map(s => (
|
||||
<div key={s.id} style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<h4 style={{ margin: 0 }}>{s.heading}</h4>
|
||||
{/* Continuity badge */}
|
||||
{sections[s.id] && (
|
||||
<ContinuityBadge sectionId={s.id} refreshToken={continuityRefresh} />
|
||||
)}
|
||||
</div>
|
||||
{sections[s.id] ? (
|
||||
<>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
|
||||
<SEOMiniPanel analysis={seoAnalysis} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -231,6 +325,7 @@ export const BlogWriter: React.FC = () => {
|
||||
// Get current state information
|
||||
const hasResearch = research !== null;
|
||||
const hasOutline = outline.length > 0;
|
||||
const isOutlineConfirmed = outlineConfirmed;
|
||||
const researchInfo = hasResearch ? {
|
||||
sources: research.sources?.length || 0,
|
||||
queries: research.search_queries?.length || 0,
|
||||
@@ -239,6 +334,14 @@ export const BlogWriter: React.FC = () => {
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational'
|
||||
} : null;
|
||||
|
||||
const outlineContext = hasOutline ? `
|
||||
OUTLINE DETAILS:
|
||||
- Total sections: ${outline.length}
|
||||
- Section headings: ${outline.map(s => s.heading).join(', ')}
|
||||
- Total target words: ${outline.reduce((sum, s) => sum + (s.target_words || 0), 0)}
|
||||
- Section breakdown: ${outline.map(s => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`).join('; ')}
|
||||
` : '';
|
||||
|
||||
const toolGuide = `
|
||||
You are the ALwrity Blog Writing Assistant. You MUST call the appropriate frontend actions (tools) to fulfill user requests.
|
||||
|
||||
@@ -252,7 +355,8 @@ ${hasResearch && researchInfo ? `
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created` : '❌ No outline generated yet'}
|
||||
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
|
||||
${outlineContext}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
@@ -261,9 +365,12 @@ Available tools:
|
||||
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
|
||||
- generateOutline()
|
||||
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
|
||||
- refineOutline(prompt?: string) - Refine outline based on user feedback
|
||||
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
|
||||
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutline(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
||||
@@ -282,20 +389,48 @@ Available tools:
|
||||
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
|
||||
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
|
||||
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
|
||||
- When user asks to generate content, call generateSection or generateAllSections
|
||||
- After outline generation, ALWAYS guide user to review and confirm the outline
|
||||
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
|
||||
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
|
||||
- When user clicks "Confirm & Generate Content", ONLY call confirmOutlineAndGenerateContent() - DO NOT automatically generate content
|
||||
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
|
||||
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
|
||||
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
|
||||
|
||||
ENGAGEMENT TACTICS:
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Content → SEO → Publish
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → SEO → Publish
|
||||
- Use encouraging language and highlight progress made
|
||||
- If user seems lost, remind them of the current stage and suggest the next step
|
||||
- When research is complete, emphasize the value of the data found and guide to outline creation
|
||||
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
|
||||
- Encourage users to make small manual edits to the outline UI before using AI for major changes
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Outline Progress Modal */}
|
||||
{/* Outline modal */}
|
||||
<OutlineProgressModal
|
||||
isVisible={outlinePolling.isPolling}
|
||||
status={outlinePolling.currentStatus}
|
||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
|
||||
error={outlinePolling.error}
|
||||
/>
|
||||
|
||||
{/* Medium generation modal */}
|
||||
<OutlineProgressModal
|
||||
isVisible={showModal}
|
||||
status={mediumPolling.currentStatus}
|
||||
progressMessages={mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''}
|
||||
error={mediumPolling.error}
|
||||
titleOverride={'📝 Generating Your Blog Content'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ const useCopilotActionTyped = useCopilotAction as any;
|
||||
interface KeywordInputFormProps {
|
||||
onKeywordsReceived?: (data: { keywords: string; blogLength: string }) => void;
|
||||
onResearchComplete?: (researchData: BlogResearchResponse) => void;
|
||||
onTaskStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
// Separate component to manage form state
|
||||
@@ -140,7 +141,7 @@ const ResearchForm: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete }) => {
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// Keyword input action with Human-in-the-Loop
|
||||
@@ -214,9 +215,13 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
|
||||
// Store the blog length in localStorage for later use
|
||||
localStorage.setItem('blog_length_target', blogLength);
|
||||
|
||||
// Start async research
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
onTaskStart?.(task_id); // Notify parent component to start polling
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
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 {
|
||||
research: BlogResearchResponse | null;
|
||||
outline: BlogOutlineSection[];
|
||||
outlineConfirmed?: boolean;
|
||||
researchPolling?: { isPolling: boolean; currentStatus: string };
|
||||
outlinePolling?: { isPolling: boolean; currentStatus: string };
|
||||
mediumPolling?: { isPolling: boolean; currentStatus: string };
|
||||
}
|
||||
|
||||
export const useSuggestions = (research: BlogResearchResponse | null, outline: BlogOutlineSection[]) => {
|
||||
export const useSuggestions = (
|
||||
research: BlogResearchResponse | null,
|
||||
outline: BlogOutlineSection[],
|
||||
outlineConfirmed: boolean = false,
|
||||
researchPolling?: { isPolling: boolean; currentStatus: string },
|
||||
outlinePolling?: { isPolling: boolean; currentStatus: string },
|
||||
mediumPolling?: { isPolling: boolean; currentStatus: string }
|
||||
) => {
|
||||
return useMemo(() => {
|
||||
const items = [] as { title: string; message: string }[];
|
||||
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
|
||||
|
||||
// Check if any background tasks are currently running
|
||||
const isResearchRunning = researchPolling?.isPolling && researchPolling?.currentStatus !== 'completed';
|
||||
const isOutlineRunning = outlinePolling?.isPolling && outlinePolling?.currentStatus !== 'completed';
|
||||
const isMediumGenerationRunning = mediumPolling?.isPolling && mediumPolling?.currentStatus !== 'completed';
|
||||
|
||||
// If research is running, show status instead of other suggestions
|
||||
if (isResearchRunning) {
|
||||
items.push({
|
||||
title: '⏳ Research in Progress...',
|
||||
message: `Research is currently running (${researchPolling?.currentStatus}). Please wait for completion.`,
|
||||
priority: 'high'
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// If outline generation is running, show status
|
||||
if (isOutlineRunning) {
|
||||
items.push({
|
||||
title: '⏳ Outline Generation in Progress...',
|
||||
message: `Outline is being generated (${outlinePolling?.currentStatus}). Please wait for completion.`,
|
||||
priority: 'high'
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// If medium generation is running, show status
|
||||
if (isMediumGenerationRunning) {
|
||||
items.push({
|
||||
title: '⏳ Content Generation in Progress...',
|
||||
message: `Blog content is being generated (${mediumPolling?.currentStatus}). Please wait for completion.`,
|
||||
priority: 'high'
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
// Normal workflow suggestions based on current state
|
||||
if (!research) {
|
||||
items.push({ title: '🔎 Start research', message: "I want to research a topic for my blog" });
|
||||
items.push({
|
||||
title: '🔎 Start Research',
|
||||
message: "I want to research a topic for my blog",
|
||||
priority: 'high'
|
||||
});
|
||||
} else if (research && outline.length === 0) {
|
||||
// Research completed, guide user to outline creation
|
||||
items.push({
|
||||
title: '🧩 Create Outline',
|
||||
message: 'Let\'s proceed to create an outline based on the research results'
|
||||
title: 'Next: Create Outline',
|
||||
message: 'Let\'s proceed to create an outline based on the research results',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '💬 Chat with Research Data',
|
||||
@@ -26,13 +78,29 @@ export const useSuggestions = (research: BlogResearchResponse | null, outline: B
|
||||
title: '🎨 Create Custom Outline',
|
||||
message: 'I want to create an outline with my own specific instructions and requirements'
|
||||
});
|
||||
} else if (outline.length > 0) {
|
||||
// Outline created, focus on content generation
|
||||
} else if (outline.length > 0 && !outlineConfirmed) {
|
||||
// Outline created but not confirmed - focus on outline review and confirmation
|
||||
items.push({
|
||||
title: 'Next: Confirm & Generate Content',
|
||||
message: 'I\'m happy with the outline, let\'s generate content for all sections',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '💬 Chat with Outline',
|
||||
message: 'I want to discuss the outline and get insights about the content structure'
|
||||
});
|
||||
items.push({
|
||||
title: '🔧 Refine Outline',
|
||||
message: 'I want to refine the outline structure based on my feedback'
|
||||
});
|
||||
items.push({
|
||||
title: '⚖️ Rebalance Word Counts',
|
||||
message: 'Rebalance word count distribution across sections'
|
||||
});
|
||||
} else if (outline.length > 0 && outlineConfirmed) {
|
||||
// Outline confirmed, focus on content generation
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
outline.forEach(s => items.push({ title: `✍️ Generate ${s.heading}`, message: `Generate the section: ${s.heading}` }));
|
||||
items.push({ title: '🔧 Refine outline', message: 'Help me refine the outline structure' });
|
||||
items.push({ title: '✨ Enhance outline', message: 'Optimize the entire outline for better flow and engagement' });
|
||||
items.push({ title: '⚖️ Rebalance word counts', message: 'Rebalance word count distribution across sections' });
|
||||
items.push({ title: '📈 Run SEO analysis', message: 'Analyze SEO for my blog post' });
|
||||
items.push({ title: '🧾 Generate SEO metadata', message: 'Generate SEO metadata and title' });
|
||||
items.push({ title: '🧪 Hallucination check', message: 'Check for any false claims in my content' });
|
||||
@@ -40,11 +108,11 @@ export const useSuggestions = (research: BlogResearchResponse | null, outline: B
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [research, outline]);
|
||||
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling]);
|
||||
};
|
||||
|
||||
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline }) => {
|
||||
const suggestions = useSuggestions(research, outline);
|
||||
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
|
||||
useSuggestions(research, outline, outlineConfirmed);
|
||||
return null; // This is just a utility component
|
||||
};
|
||||
|
||||
|
||||
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';
|
||||
@@ -24,6 +24,9 @@ export const useBlogWriterState = () => {
|
||||
// Separate research titles from AI-generated titles
|
||||
const [researchTitles, setResearchTitles] = useState<string[]>([]);
|
||||
const [aiGeneratedTitles, setAiGeneratedTitles] = useState<string[]>([]);
|
||||
|
||||
// Outline confirmation state
|
||||
const [outlineConfirmed, setOutlineConfirmed] = useState<boolean>(false);
|
||||
|
||||
// Cache recovery - restore most recent research on page load
|
||||
useEffect(() => {
|
||||
@@ -116,6 +119,8 @@ export const useBlogWriterState = () => {
|
||||
}
|
||||
}
|
||||
setOutlineTaskId(null);
|
||||
// Reset outline confirmation when new outline is generated
|
||||
setOutlineConfirmed(false);
|
||||
}, [research]);
|
||||
|
||||
// Handle outline error
|
||||
@@ -149,6 +154,36 @@ export const useBlogWriterState = () => {
|
||||
localStorage.setItem('blog_selected_title', title);
|
||||
}, [titleOptions]);
|
||||
|
||||
// Handle outline confirmation
|
||||
const handleOutlineConfirmed = useCallback(() => {
|
||||
setOutlineConfirmed(true);
|
||||
console.log('Outline confirmed by user');
|
||||
}, []);
|
||||
|
||||
// Handle outline refinement
|
||||
const handleOutlineRefined = useCallback((feedback: string) => {
|
||||
console.log('Outline refinement requested with feedback:', feedback);
|
||||
// The actual refinement will be handled by the copilot action
|
||||
}, []);
|
||||
|
||||
// Handle content updates from WYSIWYG editor
|
||||
const handleContentUpdate = useCallback((updatedSections: any[]) => {
|
||||
console.log('Content updated:', updatedSections);
|
||||
// Update sections state with new content
|
||||
const newSections: { [key: string]: string } = {};
|
||||
updatedSections.forEach(section => {
|
||||
newSections[section.id] = section.content;
|
||||
});
|
||||
setSections(newSections);
|
||||
}, [setSections]);
|
||||
|
||||
// Handle content saving
|
||||
const handleContentSave = useCallback((content: any) => {
|
||||
console.log('Content saved:', content);
|
||||
// Here you could save to backend or local storage
|
||||
// For now, just log the content
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
research,
|
||||
@@ -167,6 +202,7 @@ export const useBlogWriterState = () => {
|
||||
researchCoverage,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
outlineConfirmed,
|
||||
|
||||
// Setters
|
||||
setResearch,
|
||||
@@ -185,6 +221,7 @@ export const useBlogWriterState = () => {
|
||||
setResearchCoverage,
|
||||
setResearchTitles,
|
||||
setAiGeneratedTitles,
|
||||
setOutlineConfirmed,
|
||||
|
||||
// Handlers
|
||||
handleResearchComplete,
|
||||
@@ -193,6 +230,10 @@ export const useBlogWriterState = () => {
|
||||
handleSectionGenerated,
|
||||
handleContinuityRefresh,
|
||||
handleTitleSelect,
|
||||
handleCustomTitle
|
||||
handleCustomTitle,
|
||||
handleOutlineConfirmed,
|
||||
handleOutlineRefined,
|
||||
handleContentUpdate,
|
||||
handleContentSave
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,26 +37,37 @@ export function usePolling(
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debug state changes
|
||||
useEffect(() => {
|
||||
console.log('Polling state changed:', { isPolling, currentStatus, progressCount: progressMessages.length });
|
||||
}, [isPolling, currentStatus, progressMessages.length]);
|
||||
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const attemptsRef = useRef(0);
|
||||
const currentTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
console.log('stopPolling called');
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
console.log('Setting isPolling to false');
|
||||
setIsPolling(false);
|
||||
attemptsRef.current = 0;
|
||||
currentTaskIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback((taskId: string) => {
|
||||
console.log('startPolling called with taskId:', taskId);
|
||||
if (isPolling) {
|
||||
console.log('Already polling, stopping first');
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
currentTaskIdRef.current = taskId;
|
||||
console.log('Setting isPolling to true');
|
||||
setIsPolling(true);
|
||||
setCurrentStatus('pending');
|
||||
setProgressMessages([]);
|
||||
@@ -118,7 +129,7 @@ export function usePolling(
|
||||
// Start polling immediately, then at intervals
|
||||
poll();
|
||||
intervalRef.current = setInterval(poll, interval);
|
||||
}, [isPolling, interval, maxAttempts, onProgress, onComplete, onError, pollFunction, stopPolling, progressMessages.length]);
|
||||
}, [isPolling, interval, onProgress, onComplete, onError, pollFunction, stopPolling, progressMessages.length]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
@@ -146,3 +157,12 @@ export function useResearchPolling(options: UsePollingOptions = {}) {
|
||||
export function useOutlinePolling(options: UsePollingOptions = {}) {
|
||||
return usePolling(blogWriterApi.pollOutlineStatus, options);
|
||||
}
|
||||
|
||||
export function useMediumGenerationPolling(options: UsePollingOptions = {}) {
|
||||
// Lazy import to avoid circular: poll function from mediumBlogApi
|
||||
const pollFn = (taskId: string) => import('../services/blogWriterApi').then(m => m.mediumBlogApi.pollMediumGeneration(taskId));
|
||||
// Wrap to satisfy type
|
||||
const wrapped = (taskId: string) => pollFn(taskId) as unknown as Promise<TaskStatusResponse>;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return usePolling(wrapped, options);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiClient, aiApiClient, longRunningApiClient, pollingApiClient } from "../api/client";
|
||||
import { apiClient, aiApiClient, pollingApiClient } from "../api/client";
|
||||
|
||||
export interface PersonaInfo {
|
||||
persona_id?: string;
|
||||
@@ -68,6 +68,7 @@ export interface BlogResearchResponse {
|
||||
search_widget?: string;
|
||||
search_queries?: string[];
|
||||
grounding_metadata?: GroundingMetadata;
|
||||
original_keywords?: string[]; // Original user-provided keywords for caching
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
@@ -283,4 +284,44 @@ export const blogWriterApi = {
|
||||
}
|
||||
};
|
||||
|
||||
// Medium blog generation (≤1000 words)
|
||||
export interface MediumSectionOutlinePayload {
|
||||
id: string;
|
||||
heading: string;
|
||||
keyPoints?: string[];
|
||||
subheadings?: string[];
|
||||
keywords?: string[];
|
||||
targetWords?: number;
|
||||
references?: ResearchSource[];
|
||||
}
|
||||
|
||||
export interface MediumGenerationRequestPayload {
|
||||
title: string;
|
||||
sections: MediumSectionOutlinePayload[];
|
||||
persona?: PersonaInfo;
|
||||
tone?: string;
|
||||
audience?: string;
|
||||
globalTargetWords?: number;
|
||||
researchKeywords?: string[]; // Original research keywords for better caching
|
||||
}
|
||||
|
||||
export interface MediumGenerationResultPayload {
|
||||
success: boolean;
|
||||
title: string;
|
||||
sections: Array<{ id: string; heading: string; content: string; wordCount: number; sources?: ResearchSource[] }>;
|
||||
model?: string;
|
||||
generation_time_ms?: number;
|
||||
}
|
||||
|
||||
export const mediumBlogApi = {
|
||||
async startMediumGeneration(payload: MediumGenerationRequestPayload): Promise<{ task_id: string; status: string }> {
|
||||
const { data } = await aiApiClient.post('/api/blog/generate/medium/start', payload);
|
||||
return data;
|
||||
},
|
||||
async pollMediumGeneration(taskId: string): Promise<TaskStatusResponse & { result?: MediumGenerationResultPayload }> {
|
||||
const { data } = await pollingApiClient.get(`/api/blog/generate/medium/status/${taskId}`);
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user