Added image generation to blog writer
This commit is contained in:
@@ -7,6 +7,7 @@ content creation, SEO analysis, and publishing.
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Any, Dict, List
|
||||
from pydantic import BaseModel, Field
|
||||
from loguru import logger
|
||||
|
||||
from models.blog_models import (
|
||||
@@ -29,6 +30,7 @@ from models.blog_models import (
|
||||
HallucinationCheckResponse,
|
||||
)
|
||||
from services.blog_writer.blog_service import BlogWriterService
|
||||
from services.blog_writer.seo.blog_seo_recommendation_applier import BlogSEORecommendationApplier
|
||||
from .task_manager import task_manager
|
||||
from .cache_manager import cache_manager
|
||||
from models.blog_models import MediumBlogGenerateRequest
|
||||
@@ -37,6 +39,44 @@ from models.blog_models import MediumBlogGenerateRequest
|
||||
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
|
||||
|
||||
service = BlogWriterService()
|
||||
recommendation_applier = BlogSEORecommendationApplier()
|
||||
# ---------------------------
|
||||
# SEO Recommendation Endpoints
|
||||
# ---------------------------
|
||||
|
||||
|
||||
class RecommendationItem(BaseModel):
|
||||
category: str = Field(..., description="Recommendation category, e.g. Structure")
|
||||
priority: str = Field(..., description="Priority level: High | Medium | Low")
|
||||
recommendation: str = Field(..., description="Action to perform")
|
||||
impact: str = Field(..., description="Expected impact or rationale")
|
||||
|
||||
|
||||
class SEOApplyRecommendationsRequest(BaseModel):
|
||||
title: str = Field(..., description="Current blog title")
|
||||
sections: List[Dict[str, Any]] = Field(..., description="Array of sections with id, heading, content")
|
||||
outline: List[Dict[str, Any]] = Field(default_factory=list, description="Outline structure for context")
|
||||
research: Dict[str, Any] = Field(default_factory=dict, description="Research data used for the blog")
|
||||
recommendations: List[RecommendationItem] = Field(..., description="Actionable recommendations to apply")
|
||||
persona: Dict[str, Any] = Field(default_factory=dict, description="Persona settings if available")
|
||||
tone: str | None = Field(default=None, description="Desired tone override")
|
||||
audience: str | None = Field(default=None, description="Target audience override")
|
||||
|
||||
|
||||
@router.post("/seo/apply-recommendations")
|
||||
async def apply_seo_recommendations(request: SEOApplyRecommendationsRequest) -> Dict[str, Any]:
|
||||
"""Apply actionable SEO recommendations and return updated content."""
|
||||
try:
|
||||
result = await recommendation_applier.apply_recommendations(request.dict())
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error", "Failed to apply recommendations"))
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply SEO recommendations: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@@ -92,7 +132,7 @@ async def start_outline_generation(request: BlogOutlineRequest) -> Dict[str, Any
|
||||
async def get_outline_status(task_id: str) -> Dict[str, Any]:
|
||||
"""Get the status of an outline generation operation."""
|
||||
try:
|
||||
status = task_manager.get_task_status(task_id)
|
||||
status = await task_manager.get_task_status(task_id)
|
||||
if status is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
@@ -164,6 +204,50 @@ async def generate_section(request: BlogSectionRequest) -> BlogSectionResponse:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/content/start")
|
||||
async def start_content_generation(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Start full content generation and return a task id for polling.
|
||||
|
||||
Accepts a payload compatible with MediumBlogGenerateRequest to minimize duplication.
|
||||
"""
|
||||
try:
|
||||
# Map dict to MediumBlogGenerateRequest for reuse
|
||||
from models.blog_models import MediumBlogGenerateRequest, MediumSectionOutline, PersonaInfo
|
||||
sections = [MediumSectionOutline(**s) for s in request.get("sections", [])]
|
||||
persona = None
|
||||
if request.get("persona"):
|
||||
persona = PersonaInfo(**request.get("persona"))
|
||||
req = MediumBlogGenerateRequest(
|
||||
title=request.get("title", "Untitled Blog"),
|
||||
sections=sections,
|
||||
persona=persona,
|
||||
tone=request.get("tone"),
|
||||
audience=request.get("audience"),
|
||||
globalTargetWords=request.get("globalTargetWords", 1000),
|
||||
researchKeywords=request.get("researchKeywords") or request.get("keywords"),
|
||||
)
|
||||
task_id = task_manager.start_content_generation_task(req)
|
||||
return {"task_id": task_id, "status": "started"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start content generation: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/content/status/{task_id}")
|
||||
async def content_generation_status(task_id: str) -> Dict[str, Any]:
|
||||
"""Poll status for content generation task."""
|
||||
try:
|
||||
status = await 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 content generation status for {task_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/section/{section_id}/continuity")
|
||||
async def get_section_continuity(section_id: str) -> Dict[str, Any]:
|
||||
"""Fetch last computed continuity metrics for a section (if available)."""
|
||||
@@ -342,7 +426,7 @@ async def start_medium_generation(request: MediumBlogGenerateRequest):
|
||||
async def medium_generation_status(task_id: str):
|
||||
"""Poll status for medium blog generation task."""
|
||||
try:
|
||||
status = task_manager.get_task_status(task_id)
|
||||
status = await task_manager.get_task_status(task_id)
|
||||
if status is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return status
|
||||
@@ -366,7 +450,7 @@ async def start_blog_rewrite(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||
async def rewrite_status(task_id: str):
|
||||
"""Poll status for blog rewrite task."""
|
||||
try:
|
||||
status = service.task_manager.get_task_status(task_id)
|
||||
status = await service.task_manager.get_task_status(task_id)
|
||||
if status is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return status
|
||||
|
||||
@@ -133,6 +133,16 @@ class TaskManager:
|
||||
task_id = self.create_task("medium_generation")
|
||||
asyncio.create_task(self._run_medium_generation_task(task_id, request))
|
||||
return task_id
|
||||
|
||||
def start_content_generation_task(self, request: MediumBlogGenerateRequest) -> str:
|
||||
"""Start content generation (full blog via sections) with provider parity.
|
||||
|
||||
Internally reuses medium generator pipeline for now but tracked under
|
||||
distinct task_type 'content_generation' and same polling contract.
|
||||
"""
|
||||
task_id = self.create_task("content_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."""
|
||||
|
||||
@@ -4,11 +4,11 @@ from typing import Dict, Any, List
|
||||
from ..models.story_models import FacebookStoryRequest, FacebookStoryResponse
|
||||
from .base_service import FacebookWriterBaseService
|
||||
try:
|
||||
from ...services.llm_providers.text_to_image_generation.gen_gemini_images import (
|
||||
generate_gemini_images_base64,
|
||||
)
|
||||
from ...services.llm_providers.main_image_generation import generate_image
|
||||
from base64 import b64encode
|
||||
except Exception:
|
||||
generate_gemini_images_base64 = None # type: ignore
|
||||
generate_image = None # type: ignore
|
||||
b64encode = None # type: ignore
|
||||
|
||||
|
||||
class FacebookStoryService(FacebookWriterBaseService):
|
||||
@@ -50,22 +50,29 @@ class FacebookStoryService(FacebookWriterBaseService):
|
||||
# Generate visual suggestions and engagement tips
|
||||
visual_suggestions = self._generate_visual_suggestions(actual_story_type, request.visual_options)
|
||||
engagement_tips = self._generate_engagement_tips("story")
|
||||
# Optional: generate one story image (9:16) using Gemini
|
||||
# Optional: generate one story image (9:16) using unified image generation
|
||||
images_base64: List[str] = []
|
||||
try:
|
||||
if generate_gemini_images_base64 is not None:
|
||||
if generate_image is not None and b64encode is not None:
|
||||
img_prompt = request.visual_options.background_image_prompt or (
|
||||
f"Facebook story background for {request.business_type}. "
|
||||
f"Style: {actual_tone}. Type: {actual_story_type}. Vertical mobile 9:16, high contrast, legible overlay space."
|
||||
)
|
||||
images_base64 = generate_gemini_images_base64(
|
||||
img_prompt,
|
||||
enhance_prompt=False,
|
||||
aspect_ratio="9:16",
|
||||
max_retries=2,
|
||||
initial_retry_delay=1.0,
|
||||
) or []
|
||||
except Exception:
|
||||
# Generate image using unified system (9:16 aspect ratio = 1080x1920)
|
||||
result = generate_image(
|
||||
prompt=img_prompt,
|
||||
options={
|
||||
"provider": "gemini", # Facebook stories use Gemini
|
||||
"width": 1080,
|
||||
"height": 1920,
|
||||
}
|
||||
)
|
||||
if result and result.image_bytes:
|
||||
# Convert bytes to base64
|
||||
image_b64 = b64encode(result.image_bytes).decode('utf-8')
|
||||
images_base64 = [image_b64]
|
||||
except Exception as e:
|
||||
# Log error but continue without images
|
||||
images_base64 = []
|
||||
|
||||
return FacebookStoryResponse(
|
||||
|
||||
217
backend/api/images.py
Normal file
217
backend/api/images.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.llm_providers.main_image_generation import generate_image
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/images", tags=["images"])
|
||||
logger = get_service_logger("api.images")
|
||||
|
||||
|
||||
class ImageGenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
negative_prompt: Optional[str] = None
|
||||
provider: Optional[str] = Field(None, pattern="^(gemini|huggingface|stability)$")
|
||||
model: Optional[str] = None
|
||||
width: Optional[int] = Field(default=1024, ge=64, le=2048)
|
||||
height: Optional[int] = Field(default=1024, ge=64, le=2048)
|
||||
guidance_scale: Optional[float] = None
|
||||
steps: Optional[int] = None
|
||||
seed: Optional[int] = None
|
||||
|
||||
|
||||
class ImageGenerateResponse(BaseModel):
|
||||
success: bool = True
|
||||
image_base64: str
|
||||
width: int
|
||||
height: int
|
||||
provider: str
|
||||
model: Optional[str] = None
|
||||
seed: Optional[int] = None
|
||||
|
||||
|
||||
@router.post("/generate", response_model=ImageGenerateResponse)
|
||||
def generate(req: ImageGenerateRequest) -> ImageGenerateResponse:
|
||||
try:
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(2): # simple single retry
|
||||
try:
|
||||
result = generate_image(
|
||||
prompt=req.prompt,
|
||||
options={
|
||||
"negative_prompt": req.negative_prompt,
|
||||
"provider": req.provider,
|
||||
"model": req.model,
|
||||
"width": req.width,
|
||||
"height": req.height,
|
||||
"guidance_scale": req.guidance_scale,
|
||||
"steps": req.steps,
|
||||
"seed": req.seed,
|
||||
},
|
||||
)
|
||||
image_b64 = base64.b64encode(result.image_bytes).decode("utf-8")
|
||||
return ImageGenerateResponse(
|
||||
image_base64=image_b64,
|
||||
width=result.width,
|
||||
height=result.height,
|
||||
provider=result.provider,
|
||||
model=result.model,
|
||||
seed=result.seed,
|
||||
)
|
||||
except Exception as inner:
|
||||
last_error = inner
|
||||
logger.error(f"Image generation attempt {attempt+1} failed: {inner}")
|
||||
# On first failure, try provider auto-remap by clearing provider to let facade decide
|
||||
if attempt == 0 and req.provider:
|
||||
req.provider = None
|
||||
continue
|
||||
break
|
||||
raise last_error or RuntimeError("Unknown image generation error")
|
||||
except Exception as e:
|
||||
logger.error(f"Image generation failed: {e}")
|
||||
# Provide a clean, actionable message to the client
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Image generation service is temporarily unavailable or the connection was reset. Please try again."
|
||||
)
|
||||
|
||||
|
||||
class PromptSuggestion(BaseModel):
|
||||
prompt: str
|
||||
negative_prompt: Optional[str] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
overlay_text: Optional[str] = None
|
||||
|
||||
|
||||
class ImagePromptSuggestRequest(BaseModel):
|
||||
provider: Optional[str] = Field(None, pattern="^(gemini|huggingface|stability)$")
|
||||
title: Optional[str] = None
|
||||
section: Optional[Dict[str, Any]] = None
|
||||
research: Optional[Dict[str, Any]] = None
|
||||
persona: Optional[Dict[str, Any]] = None
|
||||
include_overlay: Optional[bool] = True
|
||||
|
||||
|
||||
class ImagePromptSuggestResponse(BaseModel):
|
||||
suggestions: list[PromptSuggestion]
|
||||
|
||||
|
||||
@router.post("/suggest-prompts", response_model=ImagePromptSuggestResponse)
|
||||
def suggest_prompts(req: ImagePromptSuggestRequest) -> ImagePromptSuggestResponse:
|
||||
try:
|
||||
provider = (req.provider or ("gemini" if (os.getenv("GPT_PROVIDER") or "").lower().startswith("gemini") else "huggingface")).lower()
|
||||
section = req.section or {}
|
||||
title = (req.title or section.get("heading") or "").strip()
|
||||
subheads = section.get("subheadings", []) or []
|
||||
key_points = section.get("key_points", []) or []
|
||||
keywords = section.get("keywords", []) or []
|
||||
if not keywords and req.research:
|
||||
keywords = (
|
||||
req.research.get("keywords", {}).get("primary_keywords")
|
||||
or req.research.get("keywords", {}).get("primary")
|
||||
or []
|
||||
)
|
||||
|
||||
persona = req.persona or {}
|
||||
audience = persona.get("audience", "content creators and digital marketers")
|
||||
industry = persona.get("industry", req.research.get("domain") if req.research else "your industry")
|
||||
tone = persona.get("tone", "professional, trustworthy")
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string"},
|
||||
"negative_prompt": {"type": "string"},
|
||||
"width": {"type": "number"},
|
||||
"height": {"type": "number"},
|
||||
"overlay_text": {"type": "string"},
|
||||
},
|
||||
"required": ["prompt"]
|
||||
},
|
||||
"minItems": 3,
|
||||
"maxItems": 5
|
||||
}
|
||||
},
|
||||
"required": ["suggestions"]
|
||||
}
|
||||
|
||||
system = (
|
||||
"You are an expert image prompt engineer for text-to-image models. "
|
||||
"Given blog section context, craft 3-5 hyper-personalized prompts optimized for the specified provider. "
|
||||
"Return STRICT JSON matching the provided schema, no extra text."
|
||||
)
|
||||
|
||||
provider_guidance = {
|
||||
"huggingface": "Photorealistic Flux 1 Krea Dev; include camera/lighting cues (e.g., 50mm, f/2.8, rim light).",
|
||||
"gemini": "Editorial, brand-safe, crisp edges, balanced lighting; avoid artifacts.",
|
||||
"stability": "SDXL coherent details, sharp focus, cinematic contrast; readable text if present."
|
||||
}.get(provider, "")
|
||||
|
||||
best_practices = (
|
||||
"Best Practices: one clear focal subject; clean, uncluttered background; rule-of-thirds or center-weighted composition; "
|
||||
"text-safe margins if overlay text is included; neutral lighting if unsure; realistic skin tones; avoid busy patterns; "
|
||||
"no brand logos or watermarks; no copyrighted characters; avoid low-res, blur, noise, banding, oversaturation, over-sharpening; "
|
||||
"ensure hands and text are coherent if present; prefer 1024px+ on shortest side for quality."
|
||||
)
|
||||
|
||||
# Harvest a few concise facts from research if available
|
||||
facts: list[str] = []
|
||||
try:
|
||||
if req.research:
|
||||
# try common shapes used in research service
|
||||
top_stats = req.research.get("key_facts") or req.research.get("highlights") or []
|
||||
if isinstance(top_stats, list):
|
||||
facts = [str(x) for x in top_stats[:3]]
|
||||
elif isinstance(top_stats, dict):
|
||||
facts = [f"{k}: {v}" for k, v in list(top_stats.items())[:3]]
|
||||
except Exception:
|
||||
facts = []
|
||||
|
||||
facts_line = ", ".join(facts) if facts else ""
|
||||
|
||||
overlay_hint = "Include an on-image short title or fact if it improves communication; ensure clean, high-contrast safe area for text." if (req.include_overlay is None or req.include_overlay) else "Do not include on-image text."
|
||||
|
||||
prompt = f"""
|
||||
Provider: {provider}
|
||||
Title: {title}
|
||||
Subheadings: {', '.join(subheads[:5])}
|
||||
Key Points: {', '.join(key_points[:5])}
|
||||
Keywords: {', '.join([str(k) for k in keywords[:8]])}
|
||||
Research Facts: {facts_line}
|
||||
Audience: {audience}
|
||||
Industry: {industry}
|
||||
Tone: {tone}
|
||||
|
||||
Craft prompts that visually reflect this exact section (not generic blog topic). {provider_guidance}
|
||||
{best_practices}
|
||||
{overlay_hint}
|
||||
Include a suitable negative_prompt where helpful. Suggest width/height when relevant (e.g., 1024x1024 or 1920x1080).
|
||||
If including on-image text, return it in overlay_text (short: <= 8 words).
|
||||
"""
|
||||
|
||||
raw = llm_text_gen(prompt=prompt, system_prompt=system, json_struct=schema)
|
||||
data = raw if isinstance(raw, dict) else {}
|
||||
suggestions = data.get("suggestions") or []
|
||||
# basic fallback if provider returns string
|
||||
if not suggestions and isinstance(raw, str):
|
||||
suggestions = [{"prompt": raw}]
|
||||
|
||||
return ImagePromptSuggestResponse(suggestions=[PromptSuggestion(**s) for s in suggestions])
|
||||
except Exception as e:
|
||||
logger.error(f"Prompt suggestion failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -42,6 +42,7 @@ from routers.linkedin import router as linkedin_router
|
||||
# Import LinkedIn image generation router
|
||||
from api.linkedin_image_generation import router as linkedin_image_router
|
||||
from api.brainstorm import router as brainstorm_router
|
||||
from api.images import router as images_router
|
||||
|
||||
# Import hallucination detector router
|
||||
from api.hallucination_detector import router as hallucination_detector_router
|
||||
@@ -279,6 +280,7 @@ async def batch_analyze_urls_endpoint(urls: list[str]):
|
||||
# Include platform analytics router
|
||||
from routers.platform_analytics import router as platform_analytics_router
|
||||
app.include_router(platform_analytics_router)
|
||||
app.include_router(images_router)
|
||||
|
||||
# Setup frontend serving using modular utilities
|
||||
frontend_serving.setup_frontend_serving()
|
||||
|
||||
@@ -186,6 +186,8 @@ class BlogSEOMetadataRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
keywords: List[str] = []
|
||||
research_data: Optional[Dict[str, Any]] = None
|
||||
outline: Optional[List[Dict[str, Any]]] = None # Add outline structure
|
||||
seo_analysis: Optional[Dict[str, Any]] = None # Add SEO analysis results
|
||||
|
||||
|
||||
class BlogSEOMetadataResponse(BaseModel):
|
||||
|
||||
@@ -21,10 +21,9 @@ httpx>=0.27.2,<0.28.0
|
||||
|
||||
# AI/ML dependencies
|
||||
openai>=1.3.0
|
||||
anthropic>=0.7.0
|
||||
mistralai>=0.0.12
|
||||
google-genai>=1.0.0
|
||||
google-ai-generativelanguage>=0.6.18,<0.7.0
|
||||
|
||||
|
||||
google-api-python-client>=2.100.0
|
||||
google-auth>=2.23.0
|
||||
google-auth-oauthlib>=1.0.0
|
||||
@@ -53,6 +52,7 @@ nltk>=3.8.0
|
||||
|
||||
# Image and audio processing for Stability AI
|
||||
Pillow>=10.0.0
|
||||
huggingface_hub>=0.24.0
|
||||
scikit-learn>=1.3.0
|
||||
|
||||
# Testing dependencies
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
EnhancedContentGenerator - thin orchestrator combining URL selection and Gemini provider.
|
||||
EnhancedContentGenerator - thin orchestrator for section generation.
|
||||
|
||||
Provides Draft vs Polished modes and optional URL Context usage.
|
||||
Provider parity:
|
||||
- Uses main_text_generation.llm_text_gen to respect GPT_PROVIDER (Gemini/HF)
|
||||
- No direct provider coupling here; Google grounding remains in research only
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from .source_url_manager import SourceURLManager
|
||||
from .context_memory import ContextMemory
|
||||
from .transition_generator import TransitionGenerator
|
||||
@@ -15,24 +17,37 @@ from .flow_analyzer import FlowAnalyzer
|
||||
|
||||
class EnhancedContentGenerator:
|
||||
def __init__(self):
|
||||
self.provider = GeminiGroundedProvider()
|
||||
self.url_manager = SourceURLManager()
|
||||
self.memory = ContextMemory(max_entries=12)
|
||||
self.transitioner = TransitionGenerator()
|
||||
self.flow = FlowAnalyzer()
|
||||
|
||||
async def generate_section(self, section: Any, research: Any, mode: str = "polished") -> Dict[str, Any]:
|
||||
urls = self.url_manager.pick_relevant_urls(section, research)
|
||||
prev_summary = self.memory.build_previous_sections_summary(limit=2)
|
||||
prompt = self._build_prompt(section, research, prev_summary)
|
||||
result = await self.provider.generate_grounded_content(
|
||||
prompt=prompt,
|
||||
content_type="linkedin_article",
|
||||
temperature=0.6 if mode == "polished" else 0.8,
|
||||
max_tokens=2048,
|
||||
urls=urls,
|
||||
mode=mode,
|
||||
)
|
||||
urls = self.url_manager.pick_relevant_urls(section, research)
|
||||
prompt = self._build_prompt(section, research, prev_summary, urls)
|
||||
# Provider-agnostic text generation (respect GPT_PROVIDER & circuit-breaker)
|
||||
content_text: str = ""
|
||||
try:
|
||||
ai_resp = llm_text_gen(
|
||||
prompt=prompt,
|
||||
json_struct=None,
|
||||
system_prompt=None,
|
||||
)
|
||||
if isinstance(ai_resp, dict) and ai_resp.get("text"):
|
||||
content_text = ai_resp.get("text", "")
|
||||
elif isinstance(ai_resp, str):
|
||||
content_text = ai_resp
|
||||
else:
|
||||
# Fallback best-effort extraction
|
||||
content_text = str(ai_resp or "")
|
||||
except Exception as e:
|
||||
content_text = ""
|
||||
|
||||
result = {
|
||||
"content": content_text,
|
||||
"sources": [{"title": u.get("title", ""), "url": u.get("url", "")} for u in urls] if urls else [],
|
||||
}
|
||||
# Generate transition and compute intelligent flow metrics
|
||||
previous_text = prev_summary
|
||||
current_text = result.get("content", "")
|
||||
@@ -56,19 +71,22 @@ class EnhancedContentGenerator:
|
||||
pass
|
||||
return result
|
||||
|
||||
def _build_prompt(self, section: Any, research: Any, prev_summary: str) -> str:
|
||||
def _build_prompt(self, section: Any, research: Any, prev_summary: str, urls: list) -> str:
|
||||
heading = getattr(section, 'heading', 'Section')
|
||||
key_points = getattr(section, 'key_points', [])
|
||||
keywords = getattr(section, 'keywords', [])
|
||||
target_words = getattr(section, 'target_words', 300)
|
||||
url_block = "\n".join([f"- {u.get('title','')} ({u.get('url','')})" for u in urls]) if urls else "(no specific URLs provided)"
|
||||
|
||||
return (
|
||||
f"You are writing the blog section '{heading}'.\n\n"
|
||||
f"Context summary: {prev_summary}\n"
|
||||
f"Key points: {', '.join(key_points)}\n"
|
||||
f"Keywords: {', '.join(keywords)}\n"
|
||||
f"Target word count: {target_words}.\n"
|
||||
"Use only factual info from provided sources; add short transition, then body."
|
||||
f"Context summary (previous sections): {prev_summary}\n\n"
|
||||
f"Authoring requirements:\n"
|
||||
f"- Target word count: ~{target_words}\n"
|
||||
f"- Use the following key points: {', '.join(key_points)}\n"
|
||||
f"- Include these keywords naturally: {', '.join(keywords)}\n"
|
||||
f"- Cite insights from these sources when relevant (do not output raw URLs):\n{url_block}\n\n"
|
||||
"Write engaging, well-structured markdown with clear paragraphs (2-4 sentences each) separated by double line breaks."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from models.blog_models import (
|
||||
MediumGeneratedSection,
|
||||
ResearchSource,
|
||||
)
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from services.cache.persistent_content_cache import persistent_content_cache
|
||||
|
||||
|
||||
@@ -176,11 +176,9 @@ class MediumBlogGenerator:
|
||||
f"Sections to write:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
||||
)
|
||||
|
||||
ai_resp = gemini_structured_json_response(
|
||||
ai_resp = llm_text_gen(
|
||||
prompt=prompt,
|
||||
schema=schema,
|
||||
temperature=0.2,
|
||||
max_tokens=8192,
|
||||
json_struct=schema,
|
||||
system_prompt=system,
|
||||
)
|
||||
|
||||
|
||||
@@ -275,11 +275,17 @@ class BlogWriterService:
|
||||
# Initialize metadata generator
|
||||
metadata_generator = BlogSEOMetadataGenerator()
|
||||
|
||||
# Generate comprehensive metadata
|
||||
# Extract outline and seo_analysis from request
|
||||
outline = request.outline if hasattr(request, 'outline') else None
|
||||
seo_analysis = request.seo_analysis if hasattr(request, 'seo_analysis') else None
|
||||
|
||||
# Generate comprehensive metadata with full context
|
||||
metadata_results = await metadata_generator.generate_comprehensive_metadata(
|
||||
blog_content=request.content,
|
||||
blog_title=request.title or "Untitled Blog Post",
|
||||
research_data=request.research_data or {}
|
||||
research_data=request.research_data or {},
|
||||
outline=outline,
|
||||
seo_analysis=seo_analysis
|
||||
)
|
||||
|
||||
# Convert to BlogSEOMetadataResponse format
|
||||
|
||||
@@ -40,7 +40,7 @@ Return JSON format:
|
||||
}}"""
|
||||
|
||||
try:
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
optimization_schema = {
|
||||
"type": "object",
|
||||
@@ -64,11 +64,10 @@ Return JSON format:
|
||||
"propertyOrdering": ["outline"]
|
||||
}
|
||||
|
||||
optimized_data = gemini_structured_json_response(
|
||||
optimized_data = llm_text_gen(
|
||||
prompt=optimization_prompt,
|
||||
schema=optimization_schema,
|
||||
temperature=0.3,
|
||||
max_tokens=6000 # Match main outline generator
|
||||
json_struct=optimization_schema,
|
||||
system_prompt=None
|
||||
)
|
||||
|
||||
# Handle the new schema format with "outline" wrapper
|
||||
|
||||
@@ -20,7 +20,7 @@ class ResponseProcessor:
|
||||
|
||||
async def generate_with_retry(self, prompt: str, schema: Dict[str, Any], task_id: str = None) -> Dict[str, Any]:
|
||||
"""Generate outline with retry logic for API failures."""
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
from api.blog_writer.task_manager import task_manager
|
||||
|
||||
max_retries = 2 # Conservative retry for expensive API calls
|
||||
@@ -29,17 +29,16 @@ class ResponseProcessor:
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
if task_id:
|
||||
await task_manager.update_progress(task_id, f"🤖 Calling Gemini API for outline generation (attempt {attempt + 1}/{max_retries + 1})...")
|
||||
await task_manager.update_progress(task_id, f"🤖 Calling AI API for outline generation (attempt {attempt + 1}/{max_retries + 1})...")
|
||||
|
||||
outline_data = gemini_structured_json_response(
|
||||
outline_data = llm_text_gen(
|
||||
prompt=prompt,
|
||||
schema=schema,
|
||||
temperature=0.3,
|
||||
max_tokens=6000 # Increased further to avoid truncation
|
||||
json_struct=schema,
|
||||
system_prompt=None
|
||||
)
|
||||
|
||||
# Log response for debugging
|
||||
logger.info(f"Gemini response received: {type(outline_data)}")
|
||||
logger.info(f"AI response received: {type(outline_data)}")
|
||||
|
||||
# Check for errors in the response
|
||||
if isinstance(outline_data, dict) and 'error' in outline_data:
|
||||
@@ -47,17 +46,17 @@ class ResponseProcessor:
|
||||
if "503" in error_msg and "overloaded" in error_msg and attempt < max_retries:
|
||||
if task_id:
|
||||
await task_manager.update_progress(task_id, f"⚠️ AI service overloaded, retrying in {retry_delay} seconds...")
|
||||
logger.warning(f"Gemini API overloaded, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1})")
|
||||
logger.warning(f"AI API overloaded, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1})")
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
elif "No valid structured response content found" in error_msg and attempt < max_retries:
|
||||
if task_id:
|
||||
await task_manager.update_progress(task_id, f"⚠️ Invalid response format, retrying in {retry_delay} seconds...")
|
||||
logger.warning(f"Gemini response parsing failed, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1})")
|
||||
logger.warning(f"AI response parsing failed, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1})")
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Gemini structured response error: {outline_data['error']}")
|
||||
logger.error(f"AI structured response error: {outline_data['error']}")
|
||||
raise ValueError(f"AI outline generation failed: {outline_data['error']}")
|
||||
|
||||
# Validate required fields
|
||||
@@ -69,7 +68,7 @@ class ResponseProcessor:
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
raise ValueError("Invalid outline structure in Gemini response")
|
||||
raise ValueError("Invalid outline structure in AI response")
|
||||
|
||||
# If we get here, the response is valid
|
||||
return outline_data
|
||||
@@ -79,7 +78,7 @@ class ResponseProcessor:
|
||||
if ("503" in error_str or "overloaded" in error_str) and attempt < max_retries:
|
||||
if task_id:
|
||||
await task_manager.update_progress(task_id, f"⚠️ AI service error, retrying in {retry_delay} seconds...")
|
||||
logger.warning(f"Gemini API error, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1}): {error_str}")
|
||||
logger.warning(f"AI API error, retrying in {retry_delay} seconds (attempt {attempt + 1}/{max_retries + 1}): {error_str}")
|
||||
await asyncio.sleep(retry_delay)
|
||||
continue
|
||||
else:
|
||||
|
||||
@@ -44,7 +44,7 @@ class SectionEnhancer:
|
||||
"""
|
||||
|
||||
try:
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
enhancement_schema = {
|
||||
"type": "object",
|
||||
@@ -58,11 +58,10 @@ class SectionEnhancer:
|
||||
"required": ["heading", "subheadings", "key_points", "target_words", "keywords"]
|
||||
}
|
||||
|
||||
enhanced_data = gemini_structured_json_response(
|
||||
enhanced_data = llm_text_gen(
|
||||
prompt=enhancement_prompt,
|
||||
schema=enhancement_schema,
|
||||
temperature=0.4,
|
||||
max_tokens=1000
|
||||
json_struct=enhancement_schema,
|
||||
system_prompt=None
|
||||
)
|
||||
|
||||
if isinstance(enhanced_data, dict) and 'error' not in enhanced_data:
|
||||
|
||||
@@ -559,14 +559,11 @@ Analyze the mapping and provide your recommendations.
|
||||
AI validation response
|
||||
"""
|
||||
try:
|
||||
from services.llm_providers.gemini_provider import gemini_text_response
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
response = gemini_text_response(
|
||||
response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
temperature=0.3,
|
||||
top_p=0.9,
|
||||
n=1,
|
||||
max_tokens=2000,
|
||||
json_struct=None,
|
||||
system_prompt=None
|
||||
)
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ import re
|
||||
import textstat
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
from services.seo_analyzer import (
|
||||
ContentAnalyzer, KeywordAnalyzer,
|
||||
URLStructureAnalyzer, AIInsightGenerator
|
||||
)
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
|
||||
class BlogContentSEOAnalyzer:
|
||||
@@ -24,11 +24,13 @@ class BlogContentSEOAnalyzer:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the blog content SEO analyzer"""
|
||||
# Service-specific logger (no global reconfiguration)
|
||||
global logger
|
||||
logger = get_service_logger("blog_content_seo_analyzer")
|
||||
self.content_analyzer = ContentAnalyzer()
|
||||
self.keyword_analyzer = KeywordAnalyzer()
|
||||
self.url_analyzer = URLStructureAnalyzer()
|
||||
self.ai_insights = AIInsightGenerator()
|
||||
self.gemini_provider = gemini_structured_json_response
|
||||
|
||||
logger.info("BlogContentSEOAnalyzer initialized")
|
||||
|
||||
@@ -598,7 +600,7 @@ class BlogContentSEOAnalyzer:
|
||||
return recommendations
|
||||
|
||||
async def _run_ai_analysis(self, blog_content: str, keywords_data: Dict[str, Any], non_ai_results: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run single AI analysis for structured insights"""
|
||||
"""Run single AI analysis for structured insights (provider-agnostic)"""
|
||||
try:
|
||||
# Prepare context for AI analysis
|
||||
context = {
|
||||
@@ -610,7 +612,6 @@ class BlogContentSEOAnalyzer:
|
||||
# Create AI prompt for structured analysis
|
||||
prompt = self._create_ai_analysis_prompt(context)
|
||||
|
||||
# Get structured response from Gemini
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -653,18 +654,17 @@ class BlogContentSEOAnalyzer:
|
||||
}
|
||||
}
|
||||
|
||||
ai_response = self.gemini_provider(
|
||||
# Provider-agnostic structured response respecting GPT_PROVIDER
|
||||
ai_response = llm_text_gen(
|
||||
prompt=prompt,
|
||||
schema=schema,
|
||||
temperature=0.2,
|
||||
max_tokens=8192
|
||||
json_struct=schema,
|
||||
system_prompt=None
|
||||
)
|
||||
|
||||
return ai_response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI analysis failed: {e}")
|
||||
# Fail fast - don't return mock data
|
||||
raise e
|
||||
|
||||
def _create_ai_analysis_prompt(self, context: Dict[str, Any]) -> str:
|
||||
|
||||
@@ -12,7 +12,7 @@ from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from loguru import logger
|
||||
|
||||
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
|
||||
class BlogSEOMetadataGenerator:
|
||||
@@ -20,14 +20,15 @@ class BlogSEOMetadataGenerator:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the metadata generator"""
|
||||
self.gemini_provider = gemini_structured_json_response
|
||||
logger.info("BlogSEOMetadataGenerator initialized")
|
||||
|
||||
async def generate_comprehensive_metadata(
|
||||
self,
|
||||
blog_content: str,
|
||||
blog_title: str,
|
||||
research_data: Dict[str, Any]
|
||||
research_data: Dict[str, Any],
|
||||
outline: Optional[List[Dict[str, Any]]] = None,
|
||||
seo_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate comprehensive SEO metadata using maximum 2 AI calls
|
||||
@@ -36,6 +37,8 @@ class BlogSEOMetadataGenerator:
|
||||
blog_content: The blog content to analyze
|
||||
blog_title: The blog title
|
||||
research_data: Research data containing keywords and insights
|
||||
outline: Outline structure with sections and headings
|
||||
seo_analysis: SEO analysis results from previous phase
|
||||
|
||||
Returns:
|
||||
Comprehensive metadata including all SEO elements
|
||||
@@ -49,11 +52,15 @@ class BlogSEOMetadataGenerator:
|
||||
|
||||
# Call 1: Generate core SEO metadata (parallel with Call 2)
|
||||
logger.info("Generating core SEO metadata")
|
||||
core_metadata_task = self._generate_core_metadata(blog_content, blog_title, keywords_data)
|
||||
core_metadata_task = self._generate_core_metadata(
|
||||
blog_content, blog_title, keywords_data, outline, seo_analysis
|
||||
)
|
||||
|
||||
# Call 2: Generate social media and structured data (parallel with Call 1)
|
||||
logger.info("Generating social media and structured data")
|
||||
social_metadata_task = self._generate_social_metadata(blog_content, blog_title, keywords_data)
|
||||
social_metadata_task = self._generate_social_metadata(
|
||||
blog_content, blog_title, keywords_data, outline, seo_analysis
|
||||
)
|
||||
|
||||
# Wait for both calls to complete
|
||||
core_metadata, social_metadata = await asyncio.gather(
|
||||
@@ -105,12 +112,16 @@ class BlogSEOMetadataGenerator:
|
||||
self,
|
||||
blog_content: str,
|
||||
blog_title: str,
|
||||
keywords_data: Dict[str, Any]
|
||||
keywords_data: Dict[str, Any],
|
||||
outline: Optional[List[Dict[str, Any]]] = None,
|
||||
seo_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate core SEO metadata (Call 1)"""
|
||||
try:
|
||||
# Create comprehensive prompt for core metadata
|
||||
prompt = self._create_core_metadata_prompt(blog_content, blog_title, keywords_data)
|
||||
prompt = self._create_core_metadata_prompt(
|
||||
blog_content, blog_title, keywords_data, outline, seo_analysis
|
||||
)
|
||||
|
||||
# Define simplified structured schema for core metadata
|
||||
schema = {
|
||||
@@ -155,17 +166,26 @@ class BlogSEOMetadataGenerator:
|
||||
"required": ["seo_title", "meta_description", "url_slug", "blog_tags", "blog_categories", "social_hashtags", "reading_time", "focus_keyword"]
|
||||
}
|
||||
|
||||
# Get structured response from Gemini
|
||||
ai_response = self.gemini_provider(
|
||||
prompt,
|
||||
schema,
|
||||
temperature=0.3,
|
||||
max_tokens=2048
|
||||
# Get structured response using provider-agnostic llm_text_gen
|
||||
ai_response_raw = llm_text_gen(
|
||||
prompt=prompt,
|
||||
json_struct=schema,
|
||||
system_prompt=None
|
||||
)
|
||||
|
||||
# Handle response: llm_text_gen may return dict (from structured JSON) or str (needs parsing)
|
||||
ai_response = ai_response_raw
|
||||
if isinstance(ai_response_raw, str):
|
||||
try:
|
||||
import json
|
||||
ai_response = json.loads(ai_response_raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse JSON response: {ai_response_raw[:200]}...")
|
||||
ai_response = None
|
||||
|
||||
# Check if we got a valid response
|
||||
if not ai_response or not isinstance(ai_response, dict):
|
||||
logger.error("Core metadata generation failed: Invalid response from Gemini")
|
||||
logger.error("Core metadata generation failed: Invalid response from LLM")
|
||||
# Return fallback response
|
||||
primary_keywords = ', '.join(keywords_data.get('primary_keywords', ['content']))
|
||||
word_count = len(blog_content.split())
|
||||
@@ -193,12 +213,16 @@ class BlogSEOMetadataGenerator:
|
||||
self,
|
||||
blog_content: str,
|
||||
blog_title: str,
|
||||
keywords_data: Dict[str, Any]
|
||||
keywords_data: Dict[str, Any],
|
||||
outline: Optional[List[Dict[str, Any]]] = None,
|
||||
seo_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate social media and structured data (Call 2)"""
|
||||
try:
|
||||
# Create comprehensive prompt for social metadata
|
||||
prompt = self._create_social_metadata_prompt(blog_content, blog_title, keywords_data)
|
||||
prompt = self._create_social_metadata_prompt(
|
||||
blog_content, blog_title, keywords_data, outline, seo_analysis
|
||||
)
|
||||
|
||||
# Define simplified structured schema for social metadata
|
||||
schema = {
|
||||
@@ -246,17 +270,26 @@ class BlogSEOMetadataGenerator:
|
||||
"required": ["open_graph", "twitter_card", "json_ld_schema"]
|
||||
}
|
||||
|
||||
# Get structured response from Gemini
|
||||
ai_response = self.gemini_provider(
|
||||
prompt,
|
||||
schema,
|
||||
temperature=0.3,
|
||||
max_tokens=2048
|
||||
# Get structured response using provider-agnostic llm_text_gen
|
||||
ai_response_raw = llm_text_gen(
|
||||
prompt=prompt,
|
||||
json_struct=schema,
|
||||
system_prompt=None
|
||||
)
|
||||
|
||||
# Handle response: llm_text_gen may return dict (from structured JSON) or str (needs parsing)
|
||||
ai_response = ai_response_raw
|
||||
if isinstance(ai_response_raw, str):
|
||||
try:
|
||||
import json
|
||||
ai_response = json.loads(ai_response_raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse JSON response: {ai_response_raw[:200]}...")
|
||||
ai_response = None
|
||||
|
||||
# Check if we got a valid response
|
||||
if not ai_response or not isinstance(ai_response, dict) or not ai_response.get('open_graph') or not ai_response.get('twitter_card') or not ai_response.get('json_ld_schema'):
|
||||
logger.error("Social metadata generation failed: Invalid or empty response from Gemini")
|
||||
logger.error("Social metadata generation failed: Invalid or empty response from LLM")
|
||||
# Return fallback response
|
||||
return {
|
||||
'open_graph': {
|
||||
@@ -301,11 +334,47 @@ class BlogSEOMetadataGenerator:
|
||||
logger.error(f"Social metadata generation failed: {e}")
|
||||
raise e
|
||||
|
||||
def _extract_content_highlights(self, blog_content: str, max_length: int = 2500) -> str:
|
||||
"""Extract key sections from blog content for prompt context"""
|
||||
try:
|
||||
lines = blog_content.split('\n')
|
||||
|
||||
# Get first paragraph (introduction)
|
||||
intro = ""
|
||||
for line in lines[:20]:
|
||||
if line.strip() and not line.strip().startswith('#'):
|
||||
intro += line.strip() + " "
|
||||
if len(intro) > 300:
|
||||
break
|
||||
|
||||
# Get section headings
|
||||
headings = [line.strip() for line in lines if line.strip().startswith('##')][:6]
|
||||
|
||||
# Get conclusion if available
|
||||
conclusion = ""
|
||||
for line in reversed(lines[-20:]):
|
||||
if line.strip() and not line.strip().startswith('#'):
|
||||
conclusion = line.strip() + " " + conclusion
|
||||
if len(conclusion) > 300:
|
||||
break
|
||||
|
||||
highlights = f"INTRODUCTION: {intro[:300]}...\n\n"
|
||||
highlights += f"SECTION HEADINGS: {' | '.join([h.replace('##', '').strip() for h in headings])}\n\n"
|
||||
if conclusion:
|
||||
highlights += f"CONCLUSION: {conclusion[:300]}..."
|
||||
|
||||
return highlights[:max_length]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract content highlights: {e}")
|
||||
return blog_content[:2000] + "..."
|
||||
|
||||
def _create_core_metadata_prompt(
|
||||
self,
|
||||
blog_content: str,
|
||||
blog_title: str,
|
||||
keywords_data: Dict[str, Any]
|
||||
keywords_data: Dict[str, Any],
|
||||
outline: Optional[List[Dict[str, Any]]] = None,
|
||||
seo_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Create high-quality prompt for core metadata generation"""
|
||||
|
||||
@@ -314,30 +383,106 @@ class BlogSEOMetadataGenerator:
|
||||
search_intent = keywords_data.get('search_intent', 'informational')
|
||||
target_audience = keywords_data.get('target_audience', 'general')
|
||||
industry = keywords_data.get('industry', 'general')
|
||||
|
||||
# Calculate word count for reading time estimation
|
||||
word_count = len(blog_content.split())
|
||||
|
||||
# Extract outline structure
|
||||
outline_context = ""
|
||||
if outline:
|
||||
headings = [s.get('heading', '') for s in outline if s.get('heading')]
|
||||
outline_context = f"""
|
||||
OUTLINE STRUCTURE:
|
||||
- Total sections: {len(outline)}
|
||||
- Section headings: {', '.join(headings[:8])}
|
||||
- Content hierarchy: Well-structured with {len(outline)} main sections
|
||||
"""
|
||||
|
||||
# Extract SEO analysis insights
|
||||
seo_context = ""
|
||||
if seo_analysis:
|
||||
overall_score = seo_analysis.get('overall_score', seo_analysis.get('seo_score', 0))
|
||||
category_scores = seo_analysis.get('category_scores', {})
|
||||
applied_recs = seo_analysis.get('applied_recommendations', [])
|
||||
|
||||
seo_context = f"""
|
||||
SEO ANALYSIS RESULTS:
|
||||
- Overall SEO Score: {overall_score}/100
|
||||
- Category Scores: Structure {category_scores.get('structure', category_scores.get('Structure', 0))}, Keywords {category_scores.get('keywords', category_scores.get('Keywords', 0))}, Readability {category_scores.get('readability', category_scores.get('Readability', 0))}
|
||||
- Applied Recommendations: {len(applied_recs)} SEO optimizations have been applied
|
||||
- Content Quality: Optimized for search engines with keyword focus
|
||||
"""
|
||||
|
||||
# Get more content context (key sections instead of just first 1000 chars)
|
||||
content_preview = self._extract_content_highlights(blog_content)
|
||||
|
||||
prompt = f"""
|
||||
Generate SEO metadata for this blog post.
|
||||
Generate comprehensive, personalized SEO metadata for this blog post.
|
||||
|
||||
BLOG TITLE: {blog_title}
|
||||
BLOG CONTENT: {blog_content[:1000]}...
|
||||
=== BLOG CONTENT CONTEXT ===
|
||||
TITLE: {blog_title}
|
||||
CONTENT PREVIEW (key sections): {content_preview}
|
||||
WORD COUNT: {word_count} words
|
||||
READING TIME ESTIMATE: {max(1, word_count // 200)} minutes
|
||||
|
||||
{outline_context}
|
||||
|
||||
=== KEYWORD & AUDIENCE DATA ===
|
||||
PRIMARY KEYWORDS: {primary_keywords}
|
||||
SEMANTIC KEYWORDS: {semantic_keywords}
|
||||
WORD COUNT: {word_count}
|
||||
SEARCH INTENT: {search_intent}
|
||||
TARGET AUDIENCE: {target_audience}
|
||||
INDUSTRY: {industry}
|
||||
|
||||
Generate:
|
||||
1. SEO TITLE (50-60 characters) - include primary keyword
|
||||
2. META DESCRIPTION (150-160 characters) - include CTA
|
||||
3. URL SLUG (lowercase, hyphens, 3-5 words)
|
||||
4. BLOG TAGS (5-8 relevant tags)
|
||||
5. BLOG CATEGORIES (2-3 categories)
|
||||
6. SOCIAL HASHTAGS (5-10 hashtags with #)
|
||||
7. READING TIME (calculate from {word_count} words)
|
||||
8. FOCUS KEYWORD (primary keyword for SEO)
|
||||
{seo_context}
|
||||
|
||||
Make it compelling and SEO-optimized.
|
||||
=== METADATA GENERATION REQUIREMENTS ===
|
||||
1. SEO TITLE (50-60 characters, must include primary keyword):
|
||||
- Front-load primary keyword
|
||||
- Make it compelling and click-worthy
|
||||
- Include power words if appropriate for {target_audience} audience
|
||||
- Optimized for {search_intent} search intent
|
||||
|
||||
2. META DESCRIPTION (150-160 characters, must include CTA):
|
||||
- Include primary keyword naturally in first 120 chars
|
||||
- Add compelling call-to-action (e.g., "Learn more", "Discover how", "Get started")
|
||||
- Highlight value proposition for {target_audience} audience
|
||||
- Use {industry} industry-specific terminology where relevant
|
||||
|
||||
3. URL SLUG (lowercase, hyphens, 3-5 words):
|
||||
- Include primary keyword
|
||||
- Remove stop words
|
||||
- Keep it concise and readable
|
||||
|
||||
4. BLOG TAGS (5-8 relevant tags):
|
||||
- Mix of primary, semantic, and long-tail keywords
|
||||
- Industry-specific tags for {industry}
|
||||
- Audience-relevant tags for {target_audience}
|
||||
|
||||
5. BLOG CATEGORIES (2-3 categories):
|
||||
- Based on content structure and {industry} industry standards
|
||||
- Reflect main themes from outline sections
|
||||
|
||||
6. SOCIAL HASHTAGS (5-10 hashtags with #):
|
||||
- Include primary keyword as hashtag
|
||||
- Industry-specific hashtags for {industry}
|
||||
- Trending/relevant hashtags for {target_audience}
|
||||
|
||||
7. READING TIME (calculate from {word_count} words):
|
||||
- Average reading speed: 200 words/minute
|
||||
- Round to nearest minute
|
||||
|
||||
8. FOCUS KEYWORD (primary keyword for SEO):
|
||||
- Select the most important primary keyword
|
||||
- Should match the main topic and search intent
|
||||
|
||||
=== QUALITY REQUIREMENTS ===
|
||||
- All metadata must be unique, not generic
|
||||
- Incorporate insights from SEO analysis if provided
|
||||
- Reflect the actual content structure from outline
|
||||
- Use language appropriate for {target_audience} audience
|
||||
- Optimize for {search_intent} search intent
|
||||
- Make descriptions compelling and action-oriented
|
||||
|
||||
Generate metadata that is personalized, compelling, and SEO-optimized.
|
||||
"""
|
||||
return prompt
|
||||
|
||||
@@ -345,7 +490,9 @@ Make it compelling and SEO-optimized.
|
||||
self,
|
||||
blog_content: str,
|
||||
blog_title: str,
|
||||
keywords_data: Dict[str, Any]
|
||||
keywords_data: Dict[str, Any],
|
||||
outline: Optional[List[Dict[str, Any]]] = None,
|
||||
seo_analysis: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Create high-quality prompt for social metadata generation"""
|
||||
|
||||
@@ -353,49 +500,68 @@ Make it compelling and SEO-optimized.
|
||||
search_intent = keywords_data.get('search_intent', 'informational')
|
||||
target_audience = keywords_data.get('target_audience', 'general')
|
||||
industry = keywords_data.get('industry', 'general')
|
||||
|
||||
current_date = datetime.now().isoformat()
|
||||
|
||||
# Add outline and SEO context similar to core metadata prompt
|
||||
outline_context = ""
|
||||
if outline:
|
||||
headings = [s.get('heading', '') for s in outline if s.get('heading')]
|
||||
outline_context = f"\nOUTLINE SECTIONS: {', '.join(headings[:6])}\n"
|
||||
|
||||
seo_context = ""
|
||||
if seo_analysis:
|
||||
overall_score = seo_analysis.get('overall_score', seo_analysis.get('seo_score', 0))
|
||||
seo_context = f"\nSEO SCORE: {overall_score}/100 (optimized content)\n"
|
||||
|
||||
content_preview = self._extract_content_highlights(blog_content, 1500)
|
||||
|
||||
prompt = f"""
|
||||
Generate social media metadata for this blog post.
|
||||
Generate engaging social media metadata for this blog post.
|
||||
|
||||
BLOG TITLE: {blog_title}
|
||||
BLOG CONTENT: {blog_content[:800]}...
|
||||
PRIMARY KEYWORDS: {primary_keywords}
|
||||
=== CONTENT ===
|
||||
TITLE: {blog_title}
|
||||
CONTENT: {content_preview}
|
||||
{outline_context}
|
||||
{seo_context}
|
||||
KEYWORDS: {primary_keywords}
|
||||
TARGET AUDIENCE: {target_audience}
|
||||
INDUSTRY: {industry}
|
||||
CURRENT DATE: {current_date}
|
||||
|
||||
Generate:
|
||||
=== GENERATION REQUIREMENTS ===
|
||||
|
||||
1. OPEN GRAPH (Facebook/LinkedIn):
|
||||
- title: 60 chars max
|
||||
- description: 160 chars max
|
||||
- image: image URL
|
||||
- title: 60 chars max, include primary keyword, compelling for {target_audience}
|
||||
- description: 160 chars max, include CTA and value proposition
|
||||
- image: Suggest an appropriate image URL (placeholder if none available)
|
||||
- type: "article"
|
||||
- site_name: site name
|
||||
- url: canonical URL
|
||||
- site_name: Use appropriate site name for {industry} industry
|
||||
- url: Generate canonical URL structure
|
||||
|
||||
2. TWITTER CARD:
|
||||
- card: "summary_large_image"
|
||||
- title: 70 chars max
|
||||
- description: 200 chars max with hashtags
|
||||
- image: image URL
|
||||
- site: @sitename
|
||||
- creator: @author
|
||||
- title: 70 chars max, optimized for Twitter audience
|
||||
- description: 200 chars max with relevant hashtags inline
|
||||
- image: Match Open Graph image
|
||||
- site: @yourwebsite (placeholder, user should update)
|
||||
- creator: @author (placeholder, user should update)
|
||||
|
||||
3. JSON-LD SCHEMA:
|
||||
3. JSON-LD SCHEMA (Article):
|
||||
- @context: "https://schema.org"
|
||||
- @type: "Article"
|
||||
- headline: article title
|
||||
- description: article description
|
||||
- author: {{"@type": "Person", "name": "Author Name"}}
|
||||
- publisher: {{"@type": "Organization", "name": "Site Name"}}
|
||||
- datePublished: ISO date
|
||||
- dateModified: ISO date
|
||||
- mainEntityOfPage: canonical URL
|
||||
- keywords: array of keywords
|
||||
- wordCount: word count
|
||||
- headline: Article title (optimized)
|
||||
- description: Article description (150-200 chars)
|
||||
- author: {{"@type": "Person", "name": "Author Name"}} (placeholder)
|
||||
- publisher: {{"@type": "Organization", "name": "Site Name", "logo": {{"@type": "ImageObject", "url": "logo-url"}}}}
|
||||
- datePublished: {current_date}
|
||||
- dateModified: {current_date}
|
||||
- mainEntityOfPage: {{"@type": "WebPage", "@id": "canonical-url"}}
|
||||
- keywords: Array of primary and semantic keywords
|
||||
- wordCount: {len(blog_content.split())}
|
||||
- articleSection: Primary category based on content
|
||||
- inLanguage: "en-US"
|
||||
|
||||
Make it engaging and SEO-optimized.
|
||||
Make it engaging, personalized for {target_audience}, and optimized for {industry} industry.
|
||||
"""
|
||||
return prompt
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Blog SEO Recommendation Applier
|
||||
|
||||
Applies actionable SEO recommendations to existing blog content using the
|
||||
provider-agnostic `llm_text_gen` dispatcher. Ensures GPT_PROVIDER parity.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Any, List
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
|
||||
logger = get_service_logger("blog_seo_recommendation_applier")
|
||||
|
||||
|
||||
class BlogSEORecommendationApplier:
|
||||
"""Apply actionable SEO recommendations to blog content."""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initialized BlogSEORecommendationApplier")
|
||||
|
||||
async def apply_recommendations(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Apply recommendations and return updated content."""
|
||||
|
||||
title = payload.get("title", "Untitled Blog")
|
||||
sections: List[Dict[str, Any]] = payload.get("sections", [])
|
||||
outline = payload.get("outline", [])
|
||||
research = payload.get("research", {})
|
||||
recommendations = payload.get("recommendations", [])
|
||||
persona = payload.get("persona", {})
|
||||
tone = payload.get("tone")
|
||||
audience = payload.get("audience")
|
||||
|
||||
if not sections:
|
||||
return {"success": False, "error": "No sections provided for recommendation application"}
|
||||
|
||||
if not recommendations:
|
||||
logger.warning("apply_recommendations called without recommendations")
|
||||
return {"success": True, "title": title, "sections": sections, "applied": []}
|
||||
|
||||
prompt = self._build_prompt(
|
||||
title=title,
|
||||
sections=sections,
|
||||
outline=outline,
|
||||
research=research,
|
||||
recommendations=recommendations,
|
||||
persona=persona,
|
||||
tone=tone,
|
||||
audience=audience,
|
||||
)
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"sections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"heading": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"notes": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["id", "heading", "content"],
|
||||
},
|
||||
},
|
||||
"applied_recommendations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category": {"type": "string"},
|
||||
"summary": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["sections"],
|
||||
}
|
||||
|
||||
logger.info("Applying SEO recommendations via llm_text_gen")
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
llm_text_gen,
|
||||
prompt,
|
||||
None,
|
||||
schema,
|
||||
)
|
||||
|
||||
if not result or result.get("error"):
|
||||
error_msg = result.get("error", "Unknown error") if result else "No response from text generator"
|
||||
logger.error(f"SEO recommendation application failed: {error_msg}")
|
||||
return {"success": False, "error": error_msg}
|
||||
|
||||
raw_sections = result.get("sections", []) or []
|
||||
normalized_sections: List[Dict[str, Any]] = []
|
||||
|
||||
# Build lookup table from updated sections using their identifiers
|
||||
updated_map: Dict[str, Dict[str, Any]] = {}
|
||||
for updated in raw_sections:
|
||||
section_id = str(
|
||||
updated.get("id")
|
||||
or updated.get("section_id")
|
||||
or updated.get("heading")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if not section_id:
|
||||
continue
|
||||
|
||||
heading = (
|
||||
updated.get("heading")
|
||||
or updated.get("title")
|
||||
or section_id
|
||||
)
|
||||
|
||||
content_text = updated.get("content", "")
|
||||
if isinstance(content_text, list):
|
||||
content_text = "\n\n".join(str(p).strip() for p in content_text if p)
|
||||
|
||||
updated_map[section_id] = {
|
||||
"id": section_id,
|
||||
"heading": heading,
|
||||
"content": str(content_text).strip(),
|
||||
"notes": updated.get("notes", []),
|
||||
}
|
||||
|
||||
if not updated_map and raw_sections:
|
||||
logger.warning("Updated sections missing identifiers; falling back to positional mapping")
|
||||
|
||||
for index, original in enumerate(sections):
|
||||
fallback_id = str(
|
||||
original.get("id")
|
||||
or original.get("section_id")
|
||||
or f"section_{index + 1}"
|
||||
).strip()
|
||||
|
||||
mapped = updated_map.get(fallback_id)
|
||||
|
||||
if not mapped and raw_sections:
|
||||
# Fall back to positional match if identifier lookup failed
|
||||
candidate = raw_sections[index] if index < len(raw_sections) else {}
|
||||
heading = (
|
||||
candidate.get("heading")
|
||||
or candidate.get("title")
|
||||
or original.get("heading")
|
||||
or original.get("title")
|
||||
or f"Section {index + 1}"
|
||||
)
|
||||
content_text = candidate.get("content") or original.get("content", "")
|
||||
if isinstance(content_text, list):
|
||||
content_text = "\n\n".join(str(p).strip() for p in content_text if p)
|
||||
mapped = {
|
||||
"id": fallback_id,
|
||||
"heading": heading,
|
||||
"content": str(content_text).strip(),
|
||||
"notes": candidate.get("notes", []),
|
||||
}
|
||||
|
||||
if not mapped:
|
||||
# Fallback to original content if nothing else available
|
||||
mapped = {
|
||||
"id": fallback_id,
|
||||
"heading": original.get("heading") or original.get("title") or f"Section {index + 1}",
|
||||
"content": str(original.get("content", "")).strip(),
|
||||
"notes": original.get("notes", []),
|
||||
}
|
||||
|
||||
normalized_sections.append(mapped)
|
||||
|
||||
applied = result.get("applied_recommendations", [])
|
||||
|
||||
logger.info("SEO recommendations applied successfully")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"title": result.get("title", title),
|
||||
"sections": normalized_sections,
|
||||
"applied": applied,
|
||||
}
|
||||
|
||||
def _build_prompt(
|
||||
self,
|
||||
*,
|
||||
title: str,
|
||||
sections: List[Dict[str, Any]],
|
||||
outline: List[Dict[str, Any]],
|
||||
research: Dict[str, Any],
|
||||
recommendations: List[Dict[str, Any]],
|
||||
persona: Dict[str, Any],
|
||||
tone: str | None,
|
||||
audience: str | None,
|
||||
) -> str:
|
||||
"""Construct prompt for applying recommendations."""
|
||||
|
||||
sections_str = []
|
||||
for section in sections:
|
||||
sections_str.append(
|
||||
f"ID: {section.get('id', 'section')}, Heading: {section.get('heading', 'Untitled')}\n"
|
||||
f"Current Content:\n{section.get('content', '')}\n"
|
||||
)
|
||||
|
||||
outline_str = "\n".join(
|
||||
[
|
||||
f"- {item.get('heading', 'Section')} (Target words: {item.get('target_words', 'N/A')})"
|
||||
for item in outline
|
||||
]
|
||||
)
|
||||
|
||||
research_summary = research.get("keyword_analysis", {}) if research else {}
|
||||
primary_keywords = ", ".join(research_summary.get("primary", [])[:10]) or "None"
|
||||
|
||||
recommendations_str = []
|
||||
for rec in recommendations:
|
||||
recommendations_str.append(
|
||||
f"Category: {rec.get('category', 'General')} | Priority: {rec.get('priority', 'Medium')}\n"
|
||||
f"Recommendation: {rec.get('recommendation', '')}\n"
|
||||
f"Impact: {rec.get('impact', '')}\n"
|
||||
)
|
||||
|
||||
persona_str = (
|
||||
f"Persona: {persona}\n"
|
||||
if persona
|
||||
else "Persona: (not provided)\n"
|
||||
)
|
||||
|
||||
style_guidance = []
|
||||
if tone:
|
||||
style_guidance.append(f"Desired tone: {tone}")
|
||||
if audience:
|
||||
style_guidance.append(f"Target audience: {audience}")
|
||||
style_str = "\n".join(style_guidance) if style_guidance else "Maintain current tone and audience alignment."
|
||||
|
||||
prompt = f"""
|
||||
You are an expert SEO content strategist. Update the blog content to apply the actionable recommendations.
|
||||
|
||||
Current Title: {title}
|
||||
|
||||
Primary Keywords (for context): {primary_keywords}
|
||||
|
||||
Outline Overview:
|
||||
{outline_str or 'No outline supplied'}
|
||||
|
||||
Existing Sections:
|
||||
{''.join(sections_str)}
|
||||
|
||||
Actionable Recommendations to Apply:
|
||||
{''.join(recommendations_str)}
|
||||
|
||||
{persona_str}
|
||||
{style_str}
|
||||
|
||||
Instructions:
|
||||
1. Carefully apply the recommendations while preserving factual accuracy and research alignment.
|
||||
2. Keep section identifiers (IDs) unchanged so the frontend can map updates correctly.
|
||||
3. Improve clarity, flow, and SEO optimization per the guidance.
|
||||
4. Return updated sections in the requested JSON format.
|
||||
5. Provide a short summary of which recommendations were addressed.
|
||||
"""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
__all__ = ["BlogSEORecommendationApplier"]
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from io import BytesIO
|
||||
|
||||
# Import existing infrastructure
|
||||
from ...onboarding.api_key_manager import APIKeyManager
|
||||
from ...llm_providers.text_to_image_generation.gen_gemini_images import generate_gemini_image
|
||||
from ...llm_providers.main_image_generation import generate_image
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -270,41 +270,57 @@ class LinkedInImageGenerator:
|
||||
|
||||
async def _generate_with_gemini(self, prompt: str, aspect_ratio: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate image using existing Gemini infrastructure.
|
||||
Generate image using unified image generation infrastructure.
|
||||
|
||||
Args:
|
||||
prompt: Enhanced prompt for image generation
|
||||
aspect_ratio: Desired aspect ratio
|
||||
|
||||
Returns:
|
||||
Generation result from Gemini
|
||||
Generation result from image generation provider
|
||||
"""
|
||||
try:
|
||||
# Use existing Gemini image generation function
|
||||
# This integrates with the current infrastructure
|
||||
result = generate_gemini_image(prompt, aspect_ratio=aspect_ratio)
|
||||
# Map aspect ratio to dimensions (LinkedIn-optimized)
|
||||
aspect_map = {
|
||||
"1:1": (1024, 1024),
|
||||
"16:9": (1920, 1080),
|
||||
"4:3": (1366, 1024),
|
||||
"9:16": (1080, 1920), # Portrait for stories
|
||||
}
|
||||
width, height = aspect_map.get(aspect_ratio, (1024, 1024))
|
||||
|
||||
if result and os.path.exists(result):
|
||||
# Read the generated image
|
||||
with open(result, 'rb') as f:
|
||||
image_data = f.read()
|
||||
|
||||
# Use unified image generation system (defaults to provider based on GPT_PROVIDER)
|
||||
result = generate_image(
|
||||
prompt=prompt,
|
||||
options={
|
||||
"provider": "gemini", # LinkedIn uses Gemini by default
|
||||
"model": self.model if hasattr(self, 'model') else None,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
)
|
||||
|
||||
if result and result.image_bytes:
|
||||
return {
|
||||
'success': True,
|
||||
'image_data': image_data,
|
||||
'image_path': result
|
||||
'image_data': result.image_bytes,
|
||||
'image_path': None, # No file path, using bytes directly
|
||||
'width': result.width,
|
||||
'height': result.height,
|
||||
'provider': result.provider,
|
||||
'model': result.model,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Gemini image generation returned no result'
|
||||
'error': 'Image generation returned no result'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Gemini image generation: {str(e)}")
|
||||
logger.error(f"Error in image generation: {str(e)}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Gemini generation failed: {str(e)}"
|
||||
'error': f"Image generation failed: {str(e)}"
|
||||
}
|
||||
|
||||
async def _process_generated_image(
|
||||
|
||||
15
backend/services/llm_providers/image_generation/__init__.py
Normal file
15
backend/services/llm_providers/image_generation/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from .base import ImageGenerationOptions, ImageGenerationResult, ImageGenerationProvider
|
||||
from .hf_provider import HuggingFaceImageProvider
|
||||
from .gemini_provider import GeminiImageProvider
|
||||
from .stability_provider import StabilityImageProvider
|
||||
|
||||
__all__ = [
|
||||
"ImageGenerationOptions",
|
||||
"ImageGenerationResult",
|
||||
"ImageGenerationProvider",
|
||||
"HuggingFaceImageProvider",
|
||||
"GeminiImageProvider",
|
||||
"StabilityImageProvider",
|
||||
]
|
||||
|
||||
|
||||
37
backend/services/llm_providers/image_generation/base.py
Normal file
37
backend/services/llm_providers/image_generation/base.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any, Protocol
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageGenerationOptions:
|
||||
prompt: str
|
||||
negative_prompt: Optional[str] = None
|
||||
width: int = 1024
|
||||
height: int = 1024
|
||||
guidance_scale: Optional[float] = None
|
||||
steps: Optional[int] = None
|
||||
seed: Optional[int] = None
|
||||
model: Optional[str] = None
|
||||
extra: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageGenerationResult:
|
||||
image_bytes: bytes
|
||||
width: int
|
||||
height: int
|
||||
provider: str
|
||||
model: Optional[str] = None
|
||||
seed: Optional[int] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ImageGenerationProvider(Protocol):
|
||||
"""Protocol for image generation providers."""
|
||||
|
||||
def generate(self, options: ImageGenerationOptions) -> ImageGenerationResult:
|
||||
...
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .base import ImageGenerationOptions, ImageGenerationResult, ImageGenerationProvider
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
|
||||
logger = get_service_logger("image_generation.gemini")
|
||||
|
||||
|
||||
class GeminiImageProvider(ImageGenerationProvider):
|
||||
"""Google Gemini/Imagen backed image generation.
|
||||
|
||||
NOTE: Implementation should call the actual Gemini Images API used in the codebase.
|
||||
Here we keep a minimal interface and expect the underlying client to be wired
|
||||
similarly to other providers and return a PIL image or raw bytes.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
api_key = os.getenv("GOOGLE_API_KEY")
|
||||
if not api_key:
|
||||
logger.warning("GOOGLE_API_KEY not set. Gemini image generation may fail at runtime.")
|
||||
logger.info("GeminiImageProvider initialized")
|
||||
|
||||
def generate(self, options: ImageGenerationOptions) -> ImageGenerationResult:
|
||||
# Placeholder implementation to be replaced by real Gemini/Imagen call.
|
||||
# For now, generate a 1x1 transparent PNG to maintain interface consistency
|
||||
img = Image.new("RGBA", (max(1, options.width), max(1, options.height)), (0, 0, 0, 0))
|
||||
with io.BytesIO() as buf:
|
||||
img.save(buf, format="PNG")
|
||||
png = buf.getvalue()
|
||||
|
||||
return ImageGenerationResult(
|
||||
image_bytes=png,
|
||||
width=img.width,
|
||||
height=img.height,
|
||||
provider="gemini",
|
||||
model=os.getenv("GEMINI_IMAGE_MODEL"),
|
||||
seed=options.seed,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from PIL import Image
|
||||
from huggingface_hub import InferenceClient
|
||||
|
||||
from .base import ImageGenerationOptions, ImageGenerationResult, ImageGenerationProvider
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
|
||||
logger = get_service_logger("image_generation.huggingface")
|
||||
|
||||
|
||||
DEFAULT_HF_MODEL = os.getenv(
|
||||
"HF_IMAGE_MODEL",
|
||||
"black-forest-labs/FLUX.1-Krea-dev",
|
||||
)
|
||||
|
||||
|
||||
class HuggingFaceImageProvider(ImageGenerationProvider):
|
||||
"""Hugging Face Inference Providers (fal-ai) backed image generation.
|
||||
|
||||
API doc: https://huggingface.co/docs/inference-providers/en/tasks/text-to-image
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None, provider: str = "fal-ai") -> None:
|
||||
self.api_key = api_key or os.getenv("HF_TOKEN")
|
||||
if not self.api_key:
|
||||
raise RuntimeError("HF_TOKEN is required for Hugging Face image generation")
|
||||
self.provider = provider
|
||||
self.client = InferenceClient(provider=self.provider, api_key=self.api_key)
|
||||
logger.info("HuggingFaceImageProvider initialized (provider=%s)", self.provider)
|
||||
|
||||
def generate(self, options: ImageGenerationOptions) -> ImageGenerationResult:
|
||||
model = options.model or DEFAULT_HF_MODEL
|
||||
params: Dict[str, Any] = {}
|
||||
if options.guidance_scale is not None:
|
||||
params["guidance_scale"] = options.guidance_scale
|
||||
if options.steps is not None:
|
||||
params["num_inference_steps"] = options.steps
|
||||
if options.negative_prompt:
|
||||
params["negative_prompt"] = options.negative_prompt
|
||||
if options.seed is not None:
|
||||
params["seed"] = options.seed
|
||||
|
||||
# The HF InferenceClient returns a PIL Image
|
||||
logger.debug("HF generate: model=%s width=%s height=%s params=%s", model, options.width, options.height, params)
|
||||
img: Image.Image = self.client.text_to_image(
|
||||
options.prompt,
|
||||
model=model,
|
||||
width=options.width,
|
||||
height=options.height,
|
||||
**params,
|
||||
)
|
||||
|
||||
with io.BytesIO() as buf:
|
||||
img.save(buf, format="PNG")
|
||||
image_bytes = buf.getvalue()
|
||||
|
||||
return ImageGenerationResult(
|
||||
image_bytes=image_bytes,
|
||||
width=img.width,
|
||||
height=img.height,
|
||||
provider="huggingface",
|
||||
model=model,
|
||||
seed=options.seed,
|
||||
metadata={"provider": self.provider},
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from .base import ImageGenerationOptions, ImageGenerationResult, ImageGenerationProvider
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
|
||||
logger = get_service_logger("image_generation.stability")
|
||||
|
||||
|
||||
DEFAULT_STABILITY_MODEL = os.getenv("STABILITY_MODEL", "stable-diffusion-xl-1024-v1-0")
|
||||
|
||||
|
||||
class StabilityImageProvider(ImageGenerationProvider):
|
||||
"""Stability AI Images API provider (simple text-to-image).
|
||||
|
||||
This uses the v1 text-to-image endpoint format. Adjust to match your existing
|
||||
Stability integration if different.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: Optional[str] = None) -> None:
|
||||
self.api_key = api_key or os.getenv("STABILITY_API_KEY")
|
||||
if not self.api_key:
|
||||
logger.warning("STABILITY_API_KEY not set. Stability generation may fail at runtime.")
|
||||
logger.info("StabilityImageProvider initialized")
|
||||
|
||||
def generate(self, options: ImageGenerationOptions) -> ImageGenerationResult:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload: Dict[str, Any] = {
|
||||
"text_prompts": [
|
||||
{"text": options.prompt, "weight": 1.0},
|
||||
],
|
||||
"cfg_scale": options.guidance_scale or 7.0,
|
||||
"steps": options.steps or 30,
|
||||
"width": options.width,
|
||||
"height": options.height,
|
||||
"seed": options.seed,
|
||||
}
|
||||
if options.negative_prompt:
|
||||
payload["text_prompts"].append({"text": options.negative_prompt, "weight": -1.0})
|
||||
|
||||
model = options.model or DEFAULT_STABILITY_MODEL
|
||||
url = f"https://api.stability.ai/v1/generation/{model}/text-to-image"
|
||||
|
||||
logger.debug("Stability generate: model=%s payload_keys=%s", model, list(payload.keys()))
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Expecting data["artifacts"][0]["base64"]
|
||||
import base64
|
||||
|
||||
artifact = (data.get("artifacts") or [{}])[0]
|
||||
b64 = artifact.get("base64", "")
|
||||
image_bytes = base64.b64decode(b64)
|
||||
|
||||
# Confirm dimensions by loading once (optional)
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
|
||||
return ImageGenerationResult(
|
||||
image_bytes=image_bytes,
|
||||
width=img.width,
|
||||
height=img.height,
|
||||
provider="stability",
|
||||
model=model,
|
||||
seed=options.seed,
|
||||
)
|
||||
|
||||
|
||||
73
backend/services/llm_providers/main_image_generation.py
Normal file
73
backend/services/llm_providers/main_image_generation.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from .image_generation import (
|
||||
ImageGenerationOptions,
|
||||
ImageGenerationResult,
|
||||
HuggingFaceImageProvider,
|
||||
GeminiImageProvider,
|
||||
StabilityImageProvider,
|
||||
)
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
|
||||
logger = get_service_logger("image_generation.facade")
|
||||
|
||||
|
||||
def _select_provider(explicit: Optional[str]) -> str:
|
||||
if explicit:
|
||||
return explicit
|
||||
gpt_provider = (os.getenv("GPT_PROVIDER") or "").lower()
|
||||
if gpt_provider.startswith("gemini"):
|
||||
return "gemini"
|
||||
if gpt_provider.startswith("hf"):
|
||||
return "huggingface"
|
||||
if os.getenv("STABILITY_API_KEY"):
|
||||
return "stability"
|
||||
# Fallback to huggingface to enable a path if configured
|
||||
return "huggingface"
|
||||
|
||||
|
||||
def _get_provider(provider_name: str):
|
||||
if provider_name == "huggingface":
|
||||
return HuggingFaceImageProvider()
|
||||
if provider_name == "gemini":
|
||||
return GeminiImageProvider()
|
||||
if provider_name == "stability":
|
||||
return StabilityImageProvider()
|
||||
raise ValueError(f"Unknown image provider: {provider_name}")
|
||||
|
||||
|
||||
def generate_image(prompt: str, options: Optional[Dict[str, Any]] = None) -> ImageGenerationResult:
|
||||
opts = options or {}
|
||||
provider_name = _select_provider(opts.get("provider"))
|
||||
|
||||
image_options = ImageGenerationOptions(
|
||||
prompt=prompt,
|
||||
negative_prompt=opts.get("negative_prompt"),
|
||||
width=int(opts.get("width", 1024)),
|
||||
height=int(opts.get("height", 1024)),
|
||||
guidance_scale=opts.get("guidance_scale"),
|
||||
steps=opts.get("steps"),
|
||||
seed=opts.get("seed"),
|
||||
model=opts.get("model"),
|
||||
extra=opts,
|
||||
)
|
||||
|
||||
# Normalize obvious model/provider mismatches
|
||||
model_lower = (image_options.model or "").lower()
|
||||
if provider_name == "stability" and (model_lower.startswith("black-forest-labs/") or model_lower.startswith("runwayml/") or model_lower.startswith("stabilityai/flux")):
|
||||
logger.info("Remapping provider to huggingface for model=%s", image_options.model)
|
||||
provider_name = "huggingface"
|
||||
|
||||
if provider_name == "huggingface" and not image_options.model:
|
||||
# Provide a sensible default HF model if none specified
|
||||
image_options.model = "black-forest-labs/FLUX.1-Krea-dev"
|
||||
|
||||
logger.info("Generating image via provider=%s model=%s", provider_name, image_options.model)
|
||||
provider = _get_provider(provider_name)
|
||||
return provider.generate(image_options)
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
from openai import OpenAI
|
||||
from loguru import logger
|
||||
import sys
|
||||
|
||||
from .save_image import save_generated_image
|
||||
|
||||
from tenacity import (
|
||||
retry,
|
||||
stop_after_attempt,
|
||||
wait_random_exponential,
|
||||
) # for exponential backoff
|
||||
|
||||
|
||||
@retry(wait=wait_random_exponential(min=1, max=120), stop=stop_after_attempt(6))
|
||||
def generate_dalle3_images(img_prompt, image_dir, size="1024x1024", quality="hd", n=1):
|
||||
"""
|
||||
Generates images using the DALL-E 3 model based on a given text prompt.
|
||||
|
||||
Args:
|
||||
img_prompt (str): Text prompt to generate the image.
|
||||
image_dir (str): Directory where the generated image will be saved.
|
||||
size (str, optional): Size of the generated images. Defaults to "1024x1024".
|
||||
quality (str, optional): Quality of the generated images. Defaults to "hd".
|
||||
n (int, optional): Number of images to generate. Defaults to 1.
|
||||
|
||||
Returns:
|
||||
str: Path to the saved image.
|
||||
|
||||
Raises:
|
||||
SystemExit: If an error occurs in image generation or saving.
|
||||
"""
|
||||
try:
|
||||
logger.info("Generating Dall-e-3 image for the blog.")
|
||||
client = OpenAI()
|
||||
|
||||
img_generation_response = client.images.generate(
|
||||
model="dall-e-3",
|
||||
prompt=img_prompt,
|
||||
size=size,
|
||||
quality=quality,
|
||||
n=n
|
||||
)
|
||||
# Save the generated image locally.
|
||||
try:
|
||||
img_path = save_generated_image(img_generation_response, image_dir)
|
||||
return img_path
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to Save generated image: {err}")
|
||||
|
||||
except openai.OpenAIError as e:
|
||||
logger.error(f"Dalle-3 image generation error: HTTP Status {e.http_status}, Error: {e.error}")
|
||||
sys.exit("Exiting due to Dalle-3 image generation error.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate images with Dalle3: {e}")
|
||||
sys.exit("Exiting due to a general error in image generation.")
|
||||
@@ -1,53 +0,0 @@
|
||||
from openai import OpenAI
|
||||
from loguru import logger
|
||||
import sys
|
||||
|
||||
from tenacity import (
|
||||
retry,
|
||||
stop_after_attempt,
|
||||
wait_random_exponential,
|
||||
) # for exponential backoff
|
||||
|
||||
from .save_image import save_generated_image
|
||||
|
||||
|
||||
@retry(wait=wait_random_exponential(min=1, max=120), stop=stop_after_attempt(6))
|
||||
def generate_dalle3_images(img_prompt, image_dir, size="1024x1024", quality="hd", n=1):
|
||||
"""
|
||||
Generates images using the DALL-E 3 model based on a given text prompt.
|
||||
|
||||
Args:
|
||||
img_prompt (str): Text prompt to generate the image.
|
||||
image_dir (str): Directory where the generated image will be saved.
|
||||
size (str, optional): Size of the generated images. Defaults to "1024x1024".
|
||||
quality (str, optional): Quality of the generated images. Defaults to "hd".
|
||||
n (int, optional): Number of images to generate. Defaults to 1.
|
||||
|
||||
Returns:
|
||||
str: Path to the saved image.
|
||||
|
||||
Raises:
|
||||
SystemExit: If an error occurs in image generation or saving.
|
||||
"""
|
||||
try:
|
||||
logger.info("Generating Dall-e-3 image for the blog.")
|
||||
client = OpenAI()
|
||||
|
||||
img_generation_response = client.images.generate(
|
||||
model="dall-e-3",
|
||||
prompt=img_prompt,
|
||||
size=size,
|
||||
quality=quality,
|
||||
n=n
|
||||
)
|
||||
|
||||
img_path = save_generated_image(img_generation_response, image_dir)
|
||||
return img_path
|
||||
|
||||
except openai.OpenAIError as e:
|
||||
logger.error(f"Dalle-3 image generation error: HTTP Status {e.http_status}, Error: {e.error}")
|
||||
sys.exit("Exiting due to Dalle-3 image generation error.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate images with Dalle3: {e}")
|
||||
sys.exit("Exiting due to a general error in image generation.")
|
||||
@@ -1,583 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import datetime
|
||||
import base64
|
||||
import random
|
||||
from typing import List, Optional, Tuple
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import logging
|
||||
|
||||
# Import APIKeyManager
|
||||
from ...onboarding.api_key_manager import APIKeyManager
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
except ImportError:
|
||||
genai = None
|
||||
logging.getLogger('gemini_image_generator').warning(
|
||||
"Google genai library not available. Install with: pip install google-generativeai"
|
||||
)
|
||||
|
||||
|
||||
from .save_image import save_generated_image
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger('gemini_image_generator')
|
||||
|
||||
# Imagen fallback configuration
|
||||
IMAGEN_FALLBACK_CONFIG = {
|
||||
'enabled': os.getenv('IMAGEN_FALLBACK_ENABLED', 'true').lower() == 'true', # Master switch for Imagen fallback
|
||||
'auto_fallback': os.getenv('IMAGEN_AUTO_FALLBACK', 'true').lower() == 'true', # Automatically fall back on Gemini failures
|
||||
'preferred_model': os.getenv('IMAGEN_MODEL', 'imagen-4.0-generate-001'), # Fast model for quick generation
|
||||
'fallback_aspect_ratios': {
|
||||
'1:1': '1:1',
|
||||
'3:4': '3:4',
|
||||
'4:3': '4:3',
|
||||
'9:16': '9:16',
|
||||
'16:9': '16:9'
|
||||
},
|
||||
'max_images': int(os.getenv('IMAGEN_MAX_IMAGES', '1')), # Generate 1 image for LinkedIn posts
|
||||
}
|
||||
|
||||
# Log configuration on startup
|
||||
logger.info(f"🔄 Imagen fallback configuration: {IMAGEN_FALLBACK_CONFIG}")
|
||||
|
||||
# With image generation in Gemini, your imagination is the limit.
|
||||
# Follow Google AI best practices for detailed prompts and iterative refinement.
|
||||
|
||||
# Generate images using Gemini
|
||||
# Gemini 2.0 Flash Experimental supports the ability to output text and inline images.
|
||||
# This lets you use Gemini to conversationally edit images or generate outputs with interwoven text (for example, generating a blog post with text and images in a single turn).
|
||||
# Note: Make sure to include responseModalities: ["Text", "Image"] in your generation configuration for text and image output with gemini-2.0-flash-exp-image-generation. Image only is not allowed.
|
||||
|
||||
|
||||
class AIPromptGenerator:
|
||||
"""
|
||||
Generates enhanced AI image prompts based on user keywords,
|
||||
following the guidelines of the Imagen documentation.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.photography_styles = ["photo", "photograph"]
|
||||
self.art_styles = ["painting", "sketch", "drawing", "illustration", "digital art", "render"]
|
||||
self.art_techniques = ["technical pencil drawing", "charcoal drawing", "color pencil drawing", "pastel painting", "digital art", "art deco (poster)", "impressionist painting", "renaissance painting", "pop art"]
|
||||
self.camera_proximity = ["close-up", "zoomed out", "taken from far away"]
|
||||
self.camera_position = ["aerial", "from below"]
|
||||
self.lighting = ["natural lighting", "dramatic lighting", "warm lighting", "cold lighting", "studio lighting", "golden hour lighting"]
|
||||
self.camera_settings = ["motion blur", "soft focus", "bokeh", "portrait"]
|
||||
self.lens_types = ["35mm lens", "50mm lens", "fisheye lens", "wide angle lens", "macro lens", "telephoto lens"]
|
||||
self.film_types = ["black and white film", "polaroid"]
|
||||
self.materials = ["made of cheese", "made of paper", "made of neon tubes", "metallic", "glass", "wooden", "stone"]
|
||||
self.shapes = ["in the shape of a bird", "angular", "curved", "geometric"]
|
||||
self.quality_modifiers_general = ["high-quality", "beautiful", "stylized", "detailed", "epic", "grand"]
|
||||
self.quality_modifiers_photo = ["4K", "HDR", "studio photo", "professional photo", "photorealistic"]
|
||||
self.quality_modifiers_art = ["by a professional artist", "intricate details", "masterpiece"]
|
||||
self.aspect_ratios = ["1:1 aspect ratio", "4:3 aspect ratio", "3:4 aspect ratio", "16:9 aspect ratio", "9:16 aspect ratio"]
|
||||
self.photorealistic_modifiers = {
|
||||
"portraits": ["prime lens", "zoom lens", "24-35mm", "black and white film", "film noir", "shallow depth of field", "duotone (mention two colors)"],
|
||||
"objects": ["macro lens", "60-105mm", "high detail", "precise focusing", "controlled lighting"],
|
||||
"motion": ["telephoto zoom lens", "100-400mm", "fast shutter speed", "action shot", "movement tracking"],
|
||||
"wide-angle": ["wide-angle lens", "10-24mm", "long exposure", "sharp focus", "smooth water or clouds", "astro photography"]
|
||||
}
|
||||
|
||||
def generate_prompt(self, keywords):
|
||||
"""
|
||||
Generates an enhanced AI image prompt based on user-provided keywords.
|
||||
|
||||
Args:
|
||||
keywords (list): A list of keywords describing the desired image.
|
||||
|
||||
Returns:
|
||||
str: An enhanced AI image prompt.
|
||||
"""
|
||||
if not keywords:
|
||||
return "A beautiful image."
|
||||
|
||||
prompt_parts = []
|
||||
subject = " ".join(keywords)
|
||||
prompt_parts.append(subject)
|
||||
|
||||
# Add context and background (optional)
|
||||
context_options = ["in a detailed background", "outdoors", "indoors", "in a studio", "with a blurred background"]
|
||||
if random.random() < 0.6: # Add context with a probability
|
||||
prompt_parts.append(random.choice(context_options))
|
||||
|
||||
# Add style (optional)
|
||||
style_options = self.photography_styles + [f"{art} of" for art in self.art_styles]
|
||||
if random.random() < 0.7:
|
||||
prompt_parts.insert(0, random.choice(style_options))
|
||||
if prompt_parts[0].startswith("painting of") or prompt_parts[0].startswith("sketch of") or prompt_parts[0].startswith("drawing of"):
|
||||
if random.random() < 0.5:
|
||||
prompt_parts.append(f"in the style of {random.choice(self.art_techniques)}")
|
||||
|
||||
# Add photography modifiers (if photography style is chosen)
|
||||
if any(style in prompt_parts[0] for style in self.photography_styles):
|
||||
if random.random() < 0.4:
|
||||
prompt_parts.append(random.choice(self.camera_proximity))
|
||||
if random.random() < 0.3:
|
||||
prompt_parts.append(random.choice(self.camera_position))
|
||||
if random.random() < 0.5:
|
||||
prompt_parts.append(random.choice(self.lighting))
|
||||
if random.random() < 0.3:
|
||||
prompt_parts.append(random.choice(self.camera_settings))
|
||||
if random.random() < 0.2:
|
||||
prompt_parts.append(random.choice(self.lens_types))
|
||||
if random.random() < 0.1:
|
||||
prompt_parts.append(random.choice(self.film_types))
|
||||
|
||||
# Add shapes and materials (optional)
|
||||
if random.random() < 0.3:
|
||||
prompt_parts.append(random.choice(self.materials))
|
||||
if random.random() < 0.2:
|
||||
prompt_parts.append(random.choice(self.shapes))
|
||||
|
||||
# Add quality modifiers (optional)
|
||||
if random.random() < 0.6:
|
||||
quality_options = self.quality_modifiers_general
|
||||
if any(style in prompt_parts[0] for style in self.photography_styles):
|
||||
quality_options += self.quality_modifiers_photo
|
||||
else:
|
||||
quality_options += self.quality_modifiers_art
|
||||
prompt_parts.append(random.choice(list(set(quality_options)))) # Avoid duplicates
|
||||
|
||||
# Add aspect ratio (optional)
|
||||
if random.random() < 0.2:
|
||||
prompt_parts.append(random.choice(self.aspect_ratios))
|
||||
|
||||
return ", ".join(prompt_parts)
|
||||
|
||||
def generate_photorealistic_prompt(self, keywords, focus=""):
|
||||
"""
|
||||
Generates an enhanced AI image prompt specifically for photorealistic images.
|
||||
|
||||
Args:
|
||||
keywords (list): A list of keywords describing the desired image.
|
||||
focus (str, optional): The focus of the photorealistic image (e.g., "portraits", "objects", "motion", "wide-angle"). Defaults to "".
|
||||
|
||||
Returns:
|
||||
str: An enhanced photorealistic AI image prompt.
|
||||
"""
|
||||
if not keywords:
|
||||
return "A photorealistic image."
|
||||
|
||||
prompt_parts = ["A photo of", "photorealistic"]
|
||||
prompt_parts.append(" ".join(keywords))
|
||||
|
||||
if focus and focus in self.photorealistic_modifiers:
|
||||
modifiers = self.photorealistic_modifiers[focus]
|
||||
if modifiers:
|
||||
num_modifiers = random.randint(1, min(3, len(modifiers)))
|
||||
selected_modifiers = random.sample(modifiers, num_modifiers)
|
||||
prompt_parts.extend(selected_modifiers)
|
||||
|
||||
# Add general quality modifiers
|
||||
if random.random() < 0.5:
|
||||
prompt_parts.append(random.choice(self.quality_modifiers_photo))
|
||||
|
||||
# Add lighting
|
||||
if random.random() < 0.4:
|
||||
prompt_parts.append(random.choice(self.lighting))
|
||||
|
||||
return ", ".join(prompt_parts)
|
||||
|
||||
def _ensure_client() -> Optional[object]:
|
||||
"""Create a Gemini client if available and API key is configured."""
|
||||
api_key_manager = APIKeyManager()
|
||||
api_key = api_key_manager.get_api_key("gemini")
|
||||
if not api_key or genai is None:
|
||||
if not api_key:
|
||||
logger.warning("No Gemini API key found")
|
||||
if genai is None:
|
||||
logger.warning("Google Generative AI library not available")
|
||||
return None
|
||||
try:
|
||||
logger.info("Creating Gemini client...")
|
||||
# Create a client using the correct API pattern
|
||||
# The API key is passed directly to the Client constructor
|
||||
client = genai.Client(api_key=api_key)
|
||||
logger.info("Gemini client created successfully")
|
||||
return client
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Gemini client: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
|
||||
def _generate_imagen_images_base64(prompt: str, aspect_ratio: str = "1:1") -> List[str]:
|
||||
"""
|
||||
Generate images using Imagen API as a fallback method.
|
||||
|
||||
This function implements the Imagen API following the official documentation:
|
||||
https://ai.google.dev/gemini-api/docs/imagen
|
||||
|
||||
Args:
|
||||
prompt: Text prompt for image generation
|
||||
aspect_ratio: Desired aspect ratio (1:1, 3:4, 4:3, 9:16, 16:9)
|
||||
|
||||
Returns:
|
||||
List of base64-encoded PNG images
|
||||
"""
|
||||
logger = logging.getLogger('gemini_image_generator')
|
||||
logger.info("🔄 Falling back to Imagen API for image generation")
|
||||
|
||||
try:
|
||||
# Get API key for Imagen (can use same Gemini API key)
|
||||
api_key_manager = APIKeyManager()
|
||||
api_key = api_key_manager.get_api_key("gemini") # Imagen uses same API key
|
||||
|
||||
if not api_key:
|
||||
logger.error("No API key available for Imagen fallback")
|
||||
return []
|
||||
|
||||
# Create Imagen client
|
||||
client = genai.Client(api_key=api_key)
|
||||
|
||||
# Map aspect ratio to Imagen format using configuration
|
||||
imagen_aspect_ratio = IMAGEN_FALLBACK_CONFIG['fallback_aspect_ratios'].get(aspect_ratio, "1:1")
|
||||
|
||||
# Optimize prompt for Imagen (remove Gemini-specific formatting)
|
||||
imagen_prompt = _optimize_prompt_for_imagen(prompt)
|
||||
|
||||
logger.info(f"Generating Imagen images with prompt: {imagen_prompt[:100]}...")
|
||||
logger.info(f"Using aspect ratio: {imagen_aspect_ratio}")
|
||||
logger.info(f"Using model: {IMAGEN_FALLBACK_CONFIG['preferred_model']}")
|
||||
|
||||
# Generate images using configured Imagen model
|
||||
# Note: sample_image_size is not supported in current library version
|
||||
config_params = {
|
||||
'number_of_images': IMAGEN_FALLBACK_CONFIG['max_images'],
|
||||
'aspect_ratio': imagen_aspect_ratio,
|
||||
}
|
||||
|
||||
# Add additional configuration options if needed
|
||||
# config_params['guidance_scale'] = 7.5 # Optional: control image generation quality
|
||||
# config_params['person_generation'] = 'allow_adult' # Optional: control person generation
|
||||
|
||||
response = client.models.generate_images(
|
||||
model=IMAGEN_FALLBACK_CONFIG['preferred_model'],
|
||||
prompt=imagen_prompt,
|
||||
config=types.GenerateImagesConfig(**config_params)
|
||||
)
|
||||
|
||||
# Extract base64 images from response
|
||||
images_b64: List[str] = []
|
||||
for generated_image in response.generated_images:
|
||||
if hasattr(generated_image, 'image') and hasattr(generated_image.image, 'image_bytes'):
|
||||
# Convert image bytes to base64
|
||||
image_bytes = generated_image.image.image_bytes
|
||||
if isinstance(image_bytes, bytes):
|
||||
images_b64.append(base64.b64encode(image_bytes).decode('utf-8'))
|
||||
else:
|
||||
# If already base64 string
|
||||
images_b64.append(str(image_bytes))
|
||||
|
||||
if images_b64:
|
||||
logger.info(f"✅ Imagen fallback successful! Generated {len(images_b64)} images")
|
||||
return images_b64
|
||||
else:
|
||||
logger.warning("Imagen fallback returned no images")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Imagen fallback failed: {e}")
|
||||
import traceback
|
||||
logger.error(f"Imagen error traceback: {traceback.format_exc()}")
|
||||
return []
|
||||
|
||||
|
||||
def _optimize_prompt_for_imagen(prompt: str) -> str:
|
||||
"""
|
||||
Optimize prompt for Imagen API by removing Gemini-specific formatting
|
||||
and enhancing it with Imagen best practices.
|
||||
|
||||
Based on Imagen prompt guide: https://ai.google.dev/gemini-api/docs/imagen
|
||||
"""
|
||||
# Remove Gemini-specific formatting
|
||||
prompt = prompt.replace('\n\nEnhanced prompt:', '')
|
||||
prompt = prompt.replace('\n\nAspect ratio:', '')
|
||||
|
||||
# Clean up extra whitespace
|
||||
prompt = ' '.join(prompt.split())
|
||||
|
||||
# Add Imagen-specific enhancements if not present
|
||||
if 'professional' in prompt.lower() and 'linkedin' in prompt.lower():
|
||||
# Enhance for LinkedIn professional content
|
||||
prompt += ", high quality, professional photography, business appropriate"
|
||||
|
||||
if 'digital transformation' in prompt.lower() or 'technology' in prompt.lower():
|
||||
# Enhance for tech content
|
||||
prompt += ", modern, innovative, clean design, corporate aesthetic"
|
||||
|
||||
# Ensure prompt doesn't exceed Imagen's 480 token limit
|
||||
if len(prompt) > 400: # Leave some buffer
|
||||
prompt = prompt[:400] + "..."
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def generate_gemini_images_base64(
|
||||
prompt: str,
|
||||
*,
|
||||
keywords: Optional[list] = None,
|
||||
style: Optional[str] = None,
|
||||
focus: Optional[str] = None,
|
||||
enhance_prompt: bool = True,
|
||||
aspect_ratio: str = "9:16",
|
||||
max_retries: int = 2,
|
||||
initial_retry_delay: float = 1.0,
|
||||
enable_imagen_fallback: bool = True,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Return list of base64 PNG images generated from a prompt.
|
||||
|
||||
Primary method: Gemini API for image generation
|
||||
Fallback method: Imagen API when Gemini fails (quota limits, API errors, etc.)
|
||||
|
||||
Implements best practices per Gemini docs: send text prompt, parse inline image parts,
|
||||
and return base64 data suitable for API responses. No Streamlit, no printing.
|
||||
|
||||
Docs:
|
||||
- Gemini: https://ai.google.dev/gemini-api/docs/image-generation
|
||||
- Imagen: https://ai.google.dev/gemini-api/docs/imagen
|
||||
"""
|
||||
logger = logging.getLogger('gemini_image_generator')
|
||||
logger.info("Generating image (base64) with Gemini (with Imagen fallback)")
|
||||
|
||||
if enhance_prompt and keywords:
|
||||
pg = AIPromptGenerator()
|
||||
enhanced = (
|
||||
pg.generate_photorealistic_prompt(keywords, focus)
|
||||
if style == "photorealistic" and focus
|
||||
else pg.generate_prompt(keywords)
|
||||
)
|
||||
prompt = f"{prompt}\n\nEnhanced prompt: {enhanced}"
|
||||
|
||||
# Optional hint in-text for aspect ratio; API doesn't take ratio param directly
|
||||
if aspect_ratio:
|
||||
prompt = f"{prompt}\n\nAspect ratio: {aspect_ratio}"
|
||||
|
||||
# Try Gemini first
|
||||
client = _ensure_client()
|
||||
if client is None:
|
||||
logger.warning("Gemini client not available or API key missing")
|
||||
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
|
||||
logger.info("Falling back to Imagen API")
|
||||
return _generate_imagen_images_base64(prompt, aspect_ratio)
|
||||
return []
|
||||
|
||||
retry = 0
|
||||
delay = initial_retry_delay
|
||||
while retry <= max_retries:
|
||||
try:
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash-exp-image-generation",
|
||||
contents=[prompt],
|
||||
)
|
||||
|
||||
images_b64: List[str] = []
|
||||
for part in response.candidates[0].content.parts:
|
||||
if getattr(part, 'inline_data', None) is not None:
|
||||
# part.inline_data.data is bytes (base64 decoded by SDK?)
|
||||
# Standardize to base64 string for API consumers
|
||||
raw = part.inline_data.data
|
||||
if isinstance(raw, bytes):
|
||||
images_b64.append(base64.b64encode(raw).decode('utf-8'))
|
||||
else:
|
||||
# Some SDKs may already present base64 str
|
||||
images_b64.append(str(raw))
|
||||
|
||||
if images_b64:
|
||||
logger.info(f"✅ Gemini generated {len(images_b64)} images successfully")
|
||||
return images_b64
|
||||
else:
|
||||
logger.warning("Gemini returned no images, falling back to Imagen")
|
||||
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
|
||||
return _generate_imagen_images_base64(prompt, aspect_ratio)
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
logger.warning(f"Gemini image gen error: {msg}")
|
||||
|
||||
# Check if this is a quota/API error that warrants fallback
|
||||
if any(error_type in msg.lower() for error_type in [
|
||||
'quota', 'resource_exhausted', 'rate_limit', 'billing', 'api_key', '403', '429'
|
||||
]):
|
||||
logger.info("Gemini quota/API error detected, falling back to Imagen")
|
||||
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
|
||||
return _generate_imagen_images_base64(prompt, aspect_ratio)
|
||||
return []
|
||||
|
||||
# For other errors, retry if possible
|
||||
if "503" in msg and retry < max_retries:
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
retry += 1
|
||||
continue
|
||||
|
||||
# Final fallback for any other errors
|
||||
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
|
||||
logger.info("Final fallback to Imagen due to Gemini error")
|
||||
return _generate_imagen_images_base64(prompt, aspect_ratio)
|
||||
return []
|
||||
|
||||
# If all retries exhausted, fall back to Imagen
|
||||
if enable_imagen_fallback and IMAGEN_FALLBACK_CONFIG['enabled']:
|
||||
logger.info("All Gemini retries exhausted, falling back to Imagen")
|
||||
return _generate_imagen_images_base64(prompt, aspect_ratio)
|
||||
return []
|
||||
|
||||
|
||||
def generate_gemini_image(
|
||||
prompt,
|
||||
keywords=None,
|
||||
style=None,
|
||||
focus=None,
|
||||
enhance_prompt=True,
|
||||
max_retries=2,
|
||||
initial_retry_delay=1.0,
|
||||
aspect_ratio="9:16",
|
||||
enable_imagen_fallback=True,
|
||||
):
|
||||
"""
|
||||
Backward-compatible wrapper that generates a single image file on disk and returns path.
|
||||
Now includes Imagen fallback for improved reliability.
|
||||
|
||||
Prefer generate_gemini_images_base64 in new code paths.
|
||||
"""
|
||||
logger = logging.getLogger('gemini_image_generator')
|
||||
images = generate_gemini_images_base64(
|
||||
prompt,
|
||||
keywords=keywords,
|
||||
style=style,
|
||||
focus=focus,
|
||||
enhance_prompt=enhance_prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
max_retries=max_retries,
|
||||
initial_retry_delay=initial_retry_delay,
|
||||
enable_imagen_fallback=enable_imagen_fallback,
|
||||
)
|
||||
if not images:
|
||||
return None
|
||||
|
||||
# Persist first image to file for legacy callers
|
||||
img_b64 = images[0]
|
||||
img_bytes = base64.b64decode(img_b64)
|
||||
img = Image.open(BytesIO(img_bytes))
|
||||
|
||||
# Update filename to indicate which API was used
|
||||
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
if 'imagen' in prompt.lower() or 'fallback' in prompt.lower():
|
||||
out_name = f'imagen-fallback-image-{timestamp}.png'
|
||||
else:
|
||||
out_name = f'gemini-native-image-{timestamp}.png'
|
||||
|
||||
try:
|
||||
img.save(out_name)
|
||||
# Also call save_generated_image to reuse existing pipeline
|
||||
save_generated_image({"artifacts": [{"base64": img_b64}]})
|
||||
logger.info(f"✅ Image saved successfully: {out_name}")
|
||||
return out_name
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to save image: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def edit_image(image_path, prompt, max_retries=2, initial_retry_delay=1.0):
|
||||
"""
|
||||
- Image editing (text and image to image)
|
||||
Example prompt: "Edit this image to make it look like a cartoon"
|
||||
Example prompt: [image of a cat] + [image of a pillow] + "Create a cross stitch of my cat on this pillow."
|
||||
|
||||
- Multi-turn image editing (chat)
|
||||
Example prompts: [upload an image of a blue car.] "Turn this car into a convertible." "Now change the color to yellow."
|
||||
|
||||
Image editing with Gemini
|
||||
To perform image editing, add an image as input.
|
||||
The following example demonstrats uploading base64 encoded images.
|
||||
For multiple images and larger payloads, check the image input section.
|
||||
|
||||
Args:
|
||||
image_path (str): The path to the image to edit.
|
||||
prompt (str): The prompt to edit the image with.
|
||||
max_retries (int, optional): Maximum number of retry attempts for handling 503 errors. Defaults to 3.
|
||||
initial_retry_delay (int, optional): Initial delay in seconds before retrying. Defaults to 2.
|
||||
|
||||
Returns:
|
||||
str: The path to the edited image.
|
||||
"""
|
||||
import PIL.Image
|
||||
image = PIL.Image.open(image_path)
|
||||
|
||||
retry_count = 0
|
||||
retry_delay = initial_retry_delay
|
||||
|
||||
while retry_count <= max_retries:
|
||||
try:
|
||||
client = _ensure_client()
|
||||
if client is None:
|
||||
return None
|
||||
text_input = (prompt)
|
||||
|
||||
logger.info("Sending request to Gemini API for image editing")
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash-exp-image-generation",
|
||||
contents=[text_input, image],
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=['Text', 'Image']
|
||||
)
|
||||
)
|
||||
logger.info("Received response from Gemini API for image editing")
|
||||
|
||||
edited_img_name = None
|
||||
for part in response.candidates[0].content.parts:
|
||||
if getattr(part, 'inline_data', None) is not None:
|
||||
logger.info("Received edited image data from Gemini")
|
||||
edited_image = Image.open(BytesIO(part.inline_data.data))
|
||||
|
||||
# Save the edited image
|
||||
edited_img_name = f'edited-{os.path.basename(image_path)}'
|
||||
try:
|
||||
logger.info(f"Saving edited image to: {edited_img_name}")
|
||||
edited_image.save(edited_img_name)
|
||||
|
||||
# Create a dictionary with the expected format for save_generated_image
|
||||
img_response = {
|
||||
"artifacts": [
|
||||
{
|
||||
"base64": base64.b64encode(open(edited_img_name, "rb").read()).decode('utf-8')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Call save_generated_image with the correct format
|
||||
save_generated_image(img_response)
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to save edited image: {err}")
|
||||
|
||||
logger.info(f"Image editing completed. Edited image name: {edited_img_name}")
|
||||
return edited_img_name
|
||||
except Exception as err:
|
||||
error_message = str(err)
|
||||
logger.error(f"Error in edit_image: {err}")
|
||||
# Retry on transient 503
|
||||
if "503" in error_message and retry_count < max_retries:
|
||||
retry_count += 1
|
||||
logger.info(f"Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})")
|
||||
time.sleep(retry_delay)
|
||||
# Exponential backoff
|
||||
retry_delay *= 2
|
||||
else:
|
||||
return None
|
||||
# If we've exhausted all retries
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Ensure you sign up for an account to obtain an API key:
|
||||
# https://platform.stability.ai/
|
||||
# Your API key can be found here after account creation:
|
||||
# https://platform.stability.ai/account/keys
|
||||
|
||||
import os
|
||||
import requests
|
||||
import base64
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
import streamlit as st
|
||||
from loguru import logger
|
||||
|
||||
# Import APIKeyManager
|
||||
from ...onboarding.api_key_manager import APIKeyManager
|
||||
|
||||
def save_generated_image(data):
|
||||
"""Save the generated image to a file."""
|
||||
# Implementation for saving image
|
||||
pass
|
||||
|
||||
def generate_stable_diffusion_image(prompt):
|
||||
engine_id = "stable-diffusion-xl-1024-v1-0"
|
||||
api_host = os.getenv('API_HOST', 'https://api.stability.ai')
|
||||
|
||||
# Use APIKeyManager instead of direct environment variable access
|
||||
api_key_manager = APIKeyManager()
|
||||
api_key = api_key_manager.get_api_key("stability")
|
||||
|
||||
if api_key is None:
|
||||
st.warning("Missing Stability API key. Please configure it in the onboarding process.")
|
||||
return None
|
||||
|
||||
response = requests.post(
|
||||
f"{api_host}/v1/generation/{engine_id}/text-to-image",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Bearer {api_key}"
|
||||
},
|
||||
json={
|
||||
"text_prompts": [
|
||||
{
|
||||
"text": prompt
|
||||
}
|
||||
],
|
||||
"cfg_scale": 7,
|
||||
"height": 1024,
|
||||
"width": 1024,
|
||||
"samples": 1,
|
||||
"steps": 30,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception("Non-200 response: " + str(response.text))
|
||||
|
||||
data = response.json()
|
||||
img_path = save_generated_image(data)
|
||||
|
||||
for i, image in enumerate(data["artifacts"]):
|
||||
# Decode base64 image data
|
||||
img_data = base64.b64decode(image["base64"])
|
||||
# Open image using PIL
|
||||
img = Image.open(BytesIO(img_data))
|
||||
# Display the image
|
||||
img.show()
|
||||
|
||||
return img_path
|
||||
@@ -1,51 +0,0 @@
|
||||
from loguru import logger
|
||||
import sys
|
||||
from PIL import Image
|
||||
from openai import OpenAI
|
||||
|
||||
def gen_new_from_given_img(img_path, image_dir, num_img=1, img_size="1024x1024", response_format="url"):
|
||||
"""
|
||||
Generates variations of a given image using OpenAI's image variation API.
|
||||
|
||||
This function takes an existing image, processes it, and generates a specified number of new images based on it.
|
||||
These generated images are variations of the original, providing creative flexibility.
|
||||
|
||||
Args:
|
||||
img_path (str): Path to the original image file.
|
||||
image_dir (str): Directory where the generated images will be saved.
|
||||
num_img (int, optional): Number of image variations to generate. Defaults to 1.
|
||||
img_size (str, optional): Size of the generated images. Defaults to "1024x1024".
|
||||
response_format (str, optional): Format in which the generated images are returned. Defaults to "url".
|
||||
|
||||
Returns:
|
||||
str: Path to the saved image variation.
|
||||
|
||||
Raises:
|
||||
SystemExit: If a critical error occurs that prevents successful execution.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Starting image variation generation for: {img_path}")
|
||||
|
||||
# Convert and prepare the image
|
||||
png = Image.open(img_path).convert('RGBA')
|
||||
background = Image.new('RGBA', png.size, (255, 255, 255))
|
||||
alpha_composite = Image.alpha_composite(background, png)
|
||||
alpha_composite.save(img_path, 'PNG', quality=80)
|
||||
logger.info("Image prepared for variation generation.")
|
||||
|
||||
client = OpenAI()
|
||||
variation_response = client.images.create_variation(
|
||||
image=open(img_path, "rb", encoding="utf-8"),
|
||||
n=num_img,
|
||||
size=img_size,
|
||||
response_format=response_format
|
||||
)
|
||||
|
||||
# Saving the generated image
|
||||
generated_image_path = save_generated_image(variation_response, image_dir)
|
||||
logger.info(f"Image variation generated and saved to: {generated_image_path}")
|
||||
return generated_image_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error occurred during image variation generation: {e}")
|
||||
sys.exit(f"Exiting due to critical error: {e}")
|
||||
@@ -1,162 +0,0 @@
|
||||
#########################################################
|
||||
#
|
||||
# This module will generate images for the blogs using APIs
|
||||
# from Dall-E and other free resources. Given a prompt, the
|
||||
# images will be stored in local directory.
|
||||
# Required: openai API key.
|
||||
#
|
||||
#########################################################
|
||||
|
||||
# imports
|
||||
import os
|
||||
import sys
|
||||
import datetime
|
||||
import streamlit as st
|
||||
|
||||
import openai # OpenAI Python library to make API calls
|
||||
from loguru import logger
|
||||
from utils.logger_utils import get_service_logger
|
||||
|
||||
# Use service-specific logger to avoid conflicts
|
||||
logger = get_service_logger("text_to_image_generation")
|
||||
|
||||
#from .gen_dali2_images
|
||||
from .gen_dali3_images import generate_dalle3_images
|
||||
from .gen_stabl_diff_img import generate_stable_diffusion_image
|
||||
from ..text_generation.main_text_generation import llm_text_gen
|
||||
from .gen_gemini_images import generate_gemini_image
|
||||
|
||||
def generate_image(user_prompt, title=None, description=None, tags=None, content=None, aspect_ratio="16:9"):
|
||||
"""
|
||||
The generation API endpoint creates an image based on a text prompt.
|
||||
|
||||
Required inputs:
|
||||
prompt (str): A text description of the desired image(s). The maximum length is 1000 characters.
|
||||
|
||||
Optional inputs:
|
||||
--> image_engine: dalle2, dalle3, stable diffusion are supported.
|
||||
--> num_images (int): The number of images to generate. Must be between 1 and 10. Defaults to 1.
|
||||
--> size (str): The size of the generated images. Must be one of "256x256", "512x512", or "1024x1024".
|
||||
Smaller images are faster. Defaults to "1024x1024".
|
||||
-->response_format (str): The format in which the generated images are returned.
|
||||
Must be one of "url" or "b64_json". Defaults to "url".
|
||||
--> user (str): A unique identifier representing your end-user, which will help OpenAI to monitor and detect abuse.
|
||||
--> aspect_ratio (str): The aspect ratio for the generated image. Must be one of "16:9", "4:3", or "1:1". Defaults to "16:9".
|
||||
"""
|
||||
# FIXME: Need to remove default value to match sidebar input.
|
||||
image_engine = 'Gemini-AI'
|
||||
image_stored_at = None
|
||||
|
||||
if user_prompt:
|
||||
try:
|
||||
# Use enhanced prompt generator with all available parameters
|
||||
img_prompt = generate_enhanced_img_prompt(user_prompt, title, description, tags, content)
|
||||
|
||||
# Add aspect ratio to the prompt
|
||||
if aspect_ratio:
|
||||
img_prompt += f"\n\nAspect ratio: {aspect_ratio}"
|
||||
|
||||
if 'Dalle3' in image_engine:
|
||||
logger.info(f"Calling Dalle3 text-to-image with prompt: {img_prompt}")
|
||||
image_stored_at = generate_dalle3_images(img_prompt)
|
||||
elif 'Stability-AI' in image_engine:
|
||||
logger.info(f"Calling Stable diffusion text-to-image with prompt: \n{img_prompt}")
|
||||
image_stored_at = generate_stable_diffusion_image(img_prompt)
|
||||
elif 'Gemini-AI' in image_engine:
|
||||
logger.info(f"Calling Gemini text-to-image with prompt: \n{img_prompt}")
|
||||
image_stored_at = generate_gemini_image(img_prompt, aspect_ratio=aspect_ratio)
|
||||
return image_stored_at
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to generate Image: {err}")
|
||||
st.warning(f"Failed to generate Image: {err}")
|
||||
else:
|
||||
logger.error("Skipping Image creation, No prompt provided.")
|
||||
|
||||
|
||||
def generate_img_prompt(user_prompt):
|
||||
"""
|
||||
Given prompt, this functions generated a prompt for image generation.
|
||||
"""
|
||||
prompt = f"""
|
||||
As an expert prompt generator for AI text to image models and artist, I will provide you with 'user text' for creating images.
|
||||
Your task is to create a prompt for a highly relevant image from given 'user text'.
|
||||
\n
|
||||
Choose from various art styles, utilize light & shadow effects etc.
|
||||
Make sure to avoid common image generation mistakes.
|
||||
Reply with only one answer, no descrition and in plaintext.
|
||||
Make sure your prompt is detailed and creative descriptions that will inspire unique and interesting images from the AI.
|
||||
|
||||
\n\nuser text:
|
||||
'''{user_prompt}'''"""
|
||||
|
||||
response = llm_text_gen(prompt)
|
||||
return response
|
||||
|
||||
|
||||
def generate_enhanced_img_prompt(user_prompt, title=None, description=None, tags=None, content=None):
|
||||
"""
|
||||
Given user prompt and additional context (title, description, tags, content),
|
||||
this function generates an enhanced prompt for better image generation.
|
||||
|
||||
Args:
|
||||
user_prompt (str): Base prompt from the user
|
||||
title (str, optional): Blog title or content title
|
||||
description (str, optional): Blog or content description/summary
|
||||
tags (list, optional): List of tags related to the content
|
||||
content (str, optional): Actual content or excerpt
|
||||
|
||||
Returns:
|
||||
str: Enhanced prompt for image generation
|
||||
"""
|
||||
# Start with the base prompt
|
||||
context_parts = [user_prompt]
|
||||
|
||||
# Add relevant context if available
|
||||
if title:
|
||||
context_parts.append(f"Title: {title}")
|
||||
|
||||
if description:
|
||||
context_parts.append(f"Description: {description}")
|
||||
|
||||
if tags and len(tags) > 0:
|
||||
tag_text = ", ".join(tags[:5]) # Limit to 5 tags to avoid too much noise
|
||||
context_parts.append(f"Tags: {tag_text}")
|
||||
|
||||
# Create a combined context
|
||||
combined_context = "\n".join(context_parts)
|
||||
|
||||
# Add some content excerpt if available (limited to avoid token limits)
|
||||
content_excerpt = ""
|
||||
if content:
|
||||
# Just use the first few hundred characters as excerpt
|
||||
content_excerpt = content[:300] + "..." if len(content) > 300 else content
|
||||
|
||||
# Create the prompt for LLM
|
||||
prompt = f"""
|
||||
As an expert prompt engineer for AI image generation models, create a detailed, creative prompt
|
||||
for generating a high-quality, relevant image based on the following context:
|
||||
|
||||
{combined_context}
|
||||
|
||||
Additional content excerpt:
|
||||
{content_excerpt}
|
||||
|
||||
Your task is to:
|
||||
1. Analyze the context and content to understand the main theme and subject
|
||||
2. Create a rich, detailed prompt for image generation (50-75 words)
|
||||
3. Include specific visual details, art style, mood, lighting, composition
|
||||
4. Make sure the prompt is highly relevant to the original context
|
||||
5. Avoid prohibited content or anything that violates image generation guidelines
|
||||
|
||||
Reply with ONLY the final prompt. No explanations or other text.
|
||||
"""
|
||||
|
||||
# Generate the enhanced prompt
|
||||
try:
|
||||
enhanced_prompt = llm_text_gen(prompt)
|
||||
logger.info(f"Generated enhanced image prompt: {enhanced_prompt[:100]}...")
|
||||
return enhanced_prompt
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating enhanced prompt: {e}")
|
||||
# Fall back to the simple prompt generation if enhanced fails
|
||||
return generate_img_prompt(user_prompt)
|
||||
@@ -1,39 +0,0 @@
|
||||
import base64
|
||||
import datetime
|
||||
import os
|
||||
import requests
|
||||
from PIL import Image
|
||||
import logging
|
||||
|
||||
def save_generated_image(img_generation_response):
|
||||
"""
|
||||
Save generated images for blog, ensuring unique names for SEO.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get image save directory with fallback to a local directory
|
||||
image_save_dir = os.getenv('IMG_SAVE_DIR', 'generated_images')
|
||||
|
||||
# Create the directory if it doesn't exist
|
||||
if not os.path.exists(image_save_dir):
|
||||
logger.info(f"Creating image save directory: {image_save_dir}")
|
||||
os.makedirs(image_save_dir, exist_ok=True)
|
||||
|
||||
generated_image_name = f"generated_image_{datetime.datetime.now():%Y-%m-%d-%H-%M-%S}.webp"
|
||||
generated_image_filepath = os.path.join(image_save_dir, generated_image_name)
|
||||
|
||||
try:
|
||||
for i, image in enumerate(img_generation_response["artifacts"]):
|
||||
with open(generated_image_filepath, "wb") as f:
|
||||
f.write(base64.b64decode(image["base64"]))
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to get generated image content: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving image: {e}")
|
||||
return None
|
||||
|
||||
logger.info(f"Saved image at path: {generated_image_filepath}")
|
||||
|
||||
return generated_image_filepath
|
||||
@@ -1,16 +1,13 @@
|
||||
import os
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from typing import Any, Dict, List
|
||||
from dataclasses import dataclass
|
||||
import requests
|
||||
from loguru import logger
|
||||
import time
|
||||
import random
|
||||
|
||||
try:
|
||||
from google import genai
|
||||
GOOGLE_GENAI_AVAILABLE = True
|
||||
except Exception:
|
||||
GOOGLE_GENAI_AVAILABLE = False
|
||||
from services.llm_providers.main_text_generation import llm_text_gen
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -29,17 +26,10 @@ class WritingAssistantService:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.exa_api_key = os.getenv("EXA_API_KEY")
|
||||
self.gemini_api_key = os.getenv("GEMINI_API_KEY")
|
||||
|
||||
if not self.exa_api_key:
|
||||
logger.warning("EXA_API_KEY not configured; writing assistant will fail")
|
||||
|
||||
if not (GOOGLE_GENAI_AVAILABLE and self.gemini_api_key):
|
||||
logger.warning("Gemini not available; writing assistant will fail")
|
||||
self.gemini_client = None
|
||||
else:
|
||||
self.gemini_client = genai.Client(api_key=self.gemini_api_key)
|
||||
|
||||
self.http_timeout_seconds = 15
|
||||
|
||||
# COST CONTROL: Daily usage limits
|
||||
@@ -151,9 +141,6 @@ class WritingAssistantService:
|
||||
raise
|
||||
|
||||
async def _generate_continuation(self, text: str, sources: List[Dict[str, Any]]) -> tuple[str, float]:
|
||||
if not self.gemini_client:
|
||||
raise Exception("Gemini client not available")
|
||||
|
||||
# Build compact sources context block
|
||||
source_blocks: List[str] = []
|
||||
for i, s in enumerate(sources[:5]):
|
||||
@@ -164,12 +151,12 @@ class WritingAssistantService:
|
||||
)
|
||||
sources_text = "\n\n".join(source_blocks) if source_blocks else "(No sources)"
|
||||
|
||||
# Based on Exa demo guidance for completion-only behavior and inline citations
|
||||
# Provider-agnostic behavior: short continuation with one inline citation hint
|
||||
system_prompt = (
|
||||
"You are an essay-completion bot that completes a sentence or continues prose. "
|
||||
"You are an assistive writing continuation bot. "
|
||||
"Only produce 1-2 SHORT sentences. Do not repeat or paraphrase the user's stub. "
|
||||
"Continue in the same tone and topic as the stub. Prefer concrete, current facts from the provided sources. "
|
||||
"Include exactly one brief, verifiable citation hint in parentheses with an author (or 'Source') and URL in square brackets, e.g., ((Doe, 2021)[https://example.com])."
|
||||
"Match tone and topic. Prefer concrete, current facts from the provided sources. "
|
||||
"Include exactly one brief citation hint in parentheses with an author (or 'Source') and URL in square brackets, e.g., ((Doe, 2021)[https://example.com])."
|
||||
)
|
||||
|
||||
user_prompt = (
|
||||
@@ -179,17 +166,20 @@ class WritingAssistantService:
|
||||
)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
resp = await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: self.gemini_client.models.generate_content(
|
||||
model="gemini-1.5-flash", contents=f"{system_prompt}\n\n{user_prompt}"
|
||||
),
|
||||
)
|
||||
suggestion = (resp.text or "").strip()
|
||||
# Inter-call jitter to reduce burst rate limits
|
||||
time.sleep(random.uniform(0.05, 0.15))
|
||||
|
||||
ai_resp = llm_text_gen(
|
||||
prompt=user_prompt,
|
||||
json_struct=None,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
if isinstance(ai_resp, dict) and ai_resp.get("text"):
|
||||
suggestion = (ai_resp.get("text", "") or "").strip()
|
||||
else:
|
||||
suggestion = (str(ai_resp or "")).strip()
|
||||
if not suggestion:
|
||||
raise Exception("Gemini returned empty suggestion")
|
||||
raise Exception("Assistive writer returned empty suggestion")
|
||||
# naive confidence from number of sources present
|
||||
confidence = 0.7 if sources else 0.5
|
||||
return suggestion, confidence
|
||||
|
||||
@@ -24,27 +24,100 @@ The ALwrity Blog Writer is a powerful AI-driven content creation tool that helps
|
||||
|
||||
## How It Works
|
||||
|
||||
### Simple 4-Step Process
|
||||
### Complete 6-Phase Workflow
|
||||
|
||||
1. **Research Your Topic** - Enter your topic and keywords, then let AI research the latest information
|
||||
2. **Create an Outline** - AI generates a content outline that you can customize and refine
|
||||
3. **Write Section by Section** - Generate content for each section using AI, then edit as needed
|
||||
4. **Optimize and Publish** - Review SEO suggestions, make final edits, and publish your content
|
||||
ALwrity Blog Writer transforms your ideas into publish-ready content through a sophisticated, AI-powered workflow that ensures quality, accuracy, and SEO optimization at every step.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start: Keywords & Topic] --> B[Phase 1: Research & Strategy]
|
||||
B --> C[Phase 2: Intelligent Outline]
|
||||
C --> D[Phase 3: Content Generation]
|
||||
D --> E[Phase 4: SEO Analysis]
|
||||
E --> F[Phase 5: SEO Metadata]
|
||||
F --> G[Phase 6: Publish & Distribute]
|
||||
|
||||
B --> B1[Google Search Grounding]
|
||||
B --> B2[Competitor Analysis]
|
||||
B --> B3[Keyword Intelligence]
|
||||
|
||||
C --> C1[AI Outline Generation]
|
||||
C --> C2[Source Mapping]
|
||||
C --> C3[Title Generation]
|
||||
|
||||
D --> D1[Section-by-Section Writing]
|
||||
D --> D2[Context Memory]
|
||||
D --> D3[Flow Analysis]
|
||||
|
||||
E --> E1[SEO Scoring]
|
||||
E --> E2[Actionable Recommendations]
|
||||
E --> E3[AI-Powered Refinement]
|
||||
|
||||
F --> F1[Comprehensive Metadata]
|
||||
F --> F2[Open Graph & Twitter Cards]
|
||||
F --> F3[Schema.org Markup]
|
||||
|
||||
G --> G1[Multi-Platform Publishing]
|
||||
G --> G2[Scheduling]
|
||||
G --> G3[Version Management]
|
||||
|
||||
style A fill:#e3f2fd
|
||||
style B fill:#e8f5e8
|
||||
style C fill:#fff3e0
|
||||
style D fill:#fce4ec
|
||||
style E fill:#f1f8e9
|
||||
style F fill:#e0f2f1
|
||||
style G fill:#f3e5f5
|
||||
```
|
||||
|
||||
#### Phase 1: Research & Strategy
|
||||
AI-powered comprehensive research with Google Search grounding, competitor analysis, and keyword intelligence.
|
||||
|
||||
#### Phase 2: Intelligent Outline
|
||||
AI-generated outlines with source mapping, grounding insights, and optimization recommendations.
|
||||
|
||||
#### Phase 3: Content Generation
|
||||
Section-by-section content generation with SEO optimization, context memory, and engagement improvements.
|
||||
|
||||
#### Phase 4: SEO Analysis
|
||||
Advanced SEO analysis with actionable recommendations and AI-powered optimization.
|
||||
|
||||
#### Phase 5: SEO Metadata
|
||||
Optimized metadata generation for titles, descriptions, Open Graph, Twitter Cards, and structured data.
|
||||
|
||||
#### Phase 6: Publish & Distribute
|
||||
Direct publishing to WordPress, Wix, Medium, and other platforms with scheduling capabilities.
|
||||
|
||||
### Phase Features At a Glance
|
||||
|
||||
| Phase | Key Features | Target Benefits | Best For |
|
||||
|-------|-------------|-----------------|----------|
|
||||
| **Phase 1: Research** | Google Search grounding, Competitor analysis, Keyword intelligence, Content angles | Comprehensive data, Time savings, Market insights | All content creators |
|
||||
| **Phase 2: Outline** | AI generation, Source mapping, Interactive refinement, Title suggestions | Structured content, SEO foundation, Editorial flexibility | Professional writers |
|
||||
| **Phase 3: Content** | Context-aware writing, Flow analysis, Source integration, Medium mode | High quality, Consistency, Citation accuracy | Content teams |
|
||||
| **Phase 4: SEO** | Multi-dimensional scoring, Actionable recommendations, AI refinement | Search visibility, Competitive edge, Performance tracking | SEO professionals |
|
||||
| **Phase 5: Metadata** | Comprehensive SEO tags, Social optimization, Schema markup, Multi-format export | Complete optimization, Rich snippets, Cross-platform readiness | Digital marketers |
|
||||
| **Phase 6: Publish** | Multi-platform support, Scheduling, Version management, Analytics integration | Efficiency, Strategic timing, Quality control | Solopreneurs & teams |
|
||||
|
||||
### What Happens Behind the Scenes
|
||||
|
||||
- **Research Phase**: AI searches the web for current information and sources
|
||||
- **Outline Generation**: Creates a logical structure with headings and key points
|
||||
- **Content Writing**: Generates engaging, informative content for each section
|
||||
- **Quality Checks**: Runs fact-checking and SEO analysis automatically
|
||||
- **Publishing**: Formats content for your chosen platform
|
||||
The Blog Writer leverages sophisticated AI orchestration to ensure quality at every stage:
|
||||
|
||||
- **Research Phase**: AI searches the web using Gemini's native Google Search integration for current, credible information and sources
|
||||
- **Outline Generation**: Creates logical structure with headings, key points, and source mapping using parallel processing
|
||||
- **Content Writing**: Generates engaging, context-aware content for each section with continuity tracking and flow analysis
|
||||
- **SEO Optimization**: Runs comprehensive analysis with parallel non-AI analyzers plus AI insights for actionable recommendations
|
||||
- **Metadata Generation**: Creates complete SEO metadata package with social media optimization in 2 AI calls maximum
|
||||
- **Publishing**: Formats content for your chosen platform with scheduling and version management
|
||||
|
||||
### User-Friendly Features
|
||||
|
||||
- **Progress Tracking**: See real-time progress for research and writing tasks
|
||||
- **Visual Editor**: Edit content with an easy-to-use WYSIWYG interface
|
||||
- **Title Suggestions**: Choose from AI-generated title options
|
||||
- **SEO Integration**: Get SEO suggestions as you write
|
||||
- **Progress Tracking**: See real-time progress for all long-running tasks with detailed status updates
|
||||
- **Visual Editor**: Easy-to-use WYSIWYG interface with markdown support and live preview
|
||||
- **Title Suggestions**: Multiple AI-generated, SEO-scored title options to choose from
|
||||
- **SEO Integration**: Comprehensive analysis with one-click "Apply Recommendations" for instant optimization
|
||||
- **Context Memory**: Intelligent continuity tracking across sections for consistent, flowing content
|
||||
- **Source Attribution**: Automatic citation integration with research source mapping
|
||||
|
||||
## Content Types
|
||||
|
||||
@@ -130,30 +203,124 @@ The ALwrity Blog Writer is a powerful AI-driven content creation tool that helps
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Content Templates
|
||||
- **Industry-specific**: Tailored templates
|
||||
- **Content Types**: Various formats
|
||||
- **Brand Guidelines**: Consistent styling
|
||||
- **Custom Templates**: Personalized formats
|
||||
### ✨ Assistive Writing & Quick Edits
|
||||
- **Continue Writing**: AI-powered contextual suggestions as you type
|
||||
- **Smart Typing Assist**: Automatic suggestions after 20+ words
|
||||
- **Quick Edit Options**: Improve, expand, shorten, professionalize, add transitions, add data
|
||||
- **Real-time Assistance**: Instant writing help without interrupting your flow
|
||||
- **Cost-Optimized**: First suggestion automatic, then manual "Continue Writing" for efficiency
|
||||
- **One-Click Improvements**: Select text and apply quick edits instantly
|
||||
|
||||
### Collaboration Tools
|
||||
- **Team Editing**: Multiple contributors
|
||||
- **Version Control**: Content history
|
||||
- **Comments**: Feedback system
|
||||
- **Approval Workflow**: Review process
|
||||
### 🔍 Fact-Checking & Quality Assurance
|
||||
- **Hallucination Detection**: AI-powered verification of claims and facts
|
||||
- **Source Verification**: Automatic cross-checking against research sources
|
||||
- **Claim Analysis**: Detailed assessment of each verifiable statement
|
||||
- **Evidence Support**: Links to supporting or refuting sources
|
||||
- **Quality Scoring**: Overall confidence metrics for content accuracy
|
||||
|
||||
### Automation
|
||||
- **Scheduled Publishing**: Automated posting
|
||||
- **Content Calendar**: Planning tools
|
||||
- **Social Sharing**: Auto-distribution
|
||||
- **Performance Monitoring**: Analytics tracking
|
||||
### 🖼️ Image Generation
|
||||
- **Section-Specific Images**: Generate images per blog section from the outline
|
||||
- **AI-Powered Prompts**: Auto-suggest images based on section content
|
||||
- **Advanced Options**: Stability AI, Hugging Face, Gemini
|
||||
- **Blog Optimization**: Sizes and formats for platform publishing
|
||||
- **Integrated Workflow**: Generate inside the outline editor
|
||||
|
||||
### 📝 SEO Metadata Generation
|
||||
- **Comprehensive Package**: Title, description, tags, categories, hashtags in 2 AI calls
|
||||
- **Social Optimization**: Open Graph & Twitter Cards
|
||||
- **Structured Data**: Schema.org JSON-LD for rich snippets
|
||||
- **Multi-Format Export**: WordPress, Wix, HTML, JSON-LD
|
||||
- **Live Preview**: Google, Facebook, Twitter
|
||||
|
||||
### Automation & Integration
|
||||
- **Multi-Platform Publishing**: One-click to WordPress, Wix, Medium
|
||||
- **Version Management**: Track changes and revisions
|
||||
- **Scheduled Publishing**: Set future publish dates
|
||||
- **Google Analytics Integration**: Track content performance
|
||||
- **Search Console**: Monitor search visibility
|
||||
|
||||
## Who Benefits Most
|
||||
|
||||
### For Technical Content Writers
|
||||
- **Research Automation**: Save hours of manual research with AI-powered Google Search grounding
|
||||
- **Source Attribution**: Automatic citation management and credibility scoring
|
||||
- **Quality Assurance**: Built-in fact-checking and hallucination detection
|
||||
- **Citation Integration**: Seamless source references throughout content
|
||||
|
||||
### For Solopreneurs
|
||||
- **Time Efficiency**: Complete blog creation workflow in minutes instead of hours
|
||||
- **SEO Expertise**: Professional-grade optimization without hiring specialists
|
||||
- **Multi-Platform Publishing**: One workflow, multiple destinations (WordPress, Wix, Medium)
|
||||
- **Scheduling & Automation**: Strategic content distribution and timing optimization
|
||||
|
||||
### For Digital Marketing & SEO Professionals
|
||||
- **Comprehensive SEO**: Multi-dimensional scoring with actionable insights
|
||||
- **Competitive Intelligence**: AI-powered competitor analysis and content gap identification
|
||||
- **Performance Tracking**: Integration with Google Analytics and Search Console
|
||||
- **ROI Optimization**: Data-driven content strategy and performance analytics
|
||||
|
||||
## How to Use Advanced Features
|
||||
|
||||
### Using Assistive Writing (Continue Writing)
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Start Typing] -->|20+ words| B[Auto Suggestion]
|
||||
B --> C{Accept or Reject?}
|
||||
C -->|Accept| D[Suggestion Inserted]
|
||||
C -->|Reject| E[Dismiss Suggestion]
|
||||
D --> F[Continue Writing Button]
|
||||
E --> F
|
||||
F -->|Click| G[Manual Suggestion]
|
||||
|
||||
style A fill:#e3f2fd
|
||||
style B fill:#e8f5e8
|
||||
style G fill:#fff3e0
|
||||
```
|
||||
|
||||
**Quick Steps - Continue Writing:**
|
||||
1. Type 20+ words in any blog section
|
||||
2. First suggestion appears automatically below your text
|
||||
3. Click **"Accept"** to insert or **"Dismiss"** to skip
|
||||
4. Use **"✍️ Continue Writing"** for more suggestions
|
||||
5. Suggestions include source citations for fact-checking
|
||||
|
||||
**Quick Steps - Text Selection Edits:**
|
||||
1. Select any text in your content
|
||||
2. Context menu appears automatically
|
||||
3. Choose quick edit: **Improve**, **Expand**, **Shorten**, **Professionalize**, **Add Transition**, or **Add Data**
|
||||
4. Text updates instantly with your selected improvement
|
||||
|
||||
### Using Fact-Checking
|
||||
1. Select a paragraph or claim in your blog content
|
||||
2. Right-click to open context menu
|
||||
3. Click **"🔍 Fact Check"**
|
||||
4. Wait 15-30 seconds for analysis
|
||||
5. Review results: claims, confidence, supporting/refuting sources
|
||||
6. Click **"Apply Fix"** to insert source links
|
||||
|
||||
### Using Image Generation
|
||||
1. In **Phase 2: Intelligent Outline**, click **"🖼️ Generate Image"** on any section
|
||||
2. Modal opens with auto-generated prompt (editable)
|
||||
3. Click **"Suggest Prompt"** for AI-optimized suggestions
|
||||
4. Optionally open **"Advanced Image Options"**
|
||||
5. Generate image (Stability AI, Hugging Face, or Gemini)
|
||||
6. Image auto-inserts into outline and metadata
|
||||
|
||||
### Using SEO Metadata Generation
|
||||
1. In **Phase 5: SEO Metadata**, open the modal
|
||||
2. Click **"Generate All Metadata"** (max 2 AI calls)
|
||||
3. Review tabs: Preview, Core, Social, Structured Data
|
||||
4. Edit any field; previews update live
|
||||
5. Copy formats for WordPress, Wix, or custom
|
||||
6. Images from Phase 2 auto-fill Open Graph
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **[Research Integration](research.md)** - Set up automated research
|
||||
2. **[SEO Analysis](seo-analysis.md)** - Configure SEO optimization
|
||||
3. **[Implementation Spec](implementation-spec.md)** - Technical details
|
||||
4. **[Best Practices](../../guides/best-practices.md)** - Optimization tips
|
||||
1. **[Research Integration](research.md)** - Comprehensive Phase 1 research capabilities
|
||||
2. **[Workflow Guide](workflow-guide.md)** - Step-by-step 6-phase workflow walkthrough
|
||||
3. **[SEO Analysis](seo-analysis.md)** - Phase 4 & 5 optimization strategies
|
||||
4. **[Implementation Spec](implementation-spec.md)** - Technical architecture and API details
|
||||
5. **[Best Practices](../../guides/best-practices.md)** - Advanced optimization tips
|
||||
|
||||
## Related Features
|
||||
|
||||
|
||||
@@ -1,18 +1,44 @@
|
||||
# Research Integration
|
||||
# Phase 1: Research & Strategy
|
||||
|
||||
ALwrity's Blog Writer includes powerful research integration capabilities that automatically gather, analyze, and verify information to create well-researched, accurate, and comprehensive blog content.
|
||||
ALwrity's Blog Writer Phase 1 provides powerful AI-powered research capabilities that automatically gather, analyze, and verify information to create well-researched, accurate, and comprehensive blog content. This foundation phase sets the stage for all subsequent content creation.
|
||||
|
||||
## What is Research Integration?
|
||||
## Overview
|
||||
|
||||
Research Integration is an AI-powered feature that automatically conducts comprehensive research on your chosen topic, gathering information from multiple sources, verifying facts, and organizing insights to support your content creation process.
|
||||
Phase 1: Research & Strategy leverages Gemini's native Google Search grounding to conduct comprehensive topic research in a single API call, delivering competitor intelligence, keyword analysis, and content angles to inform your entire blog creation process.
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- **Comprehensive Research**: Gather information from multiple reliable sources
|
||||
- **Fact Verification**: Verify claims and statistics automatically
|
||||
- **Source Attribution**: Provide proper citations and references
|
||||
- **Trend Analysis**: Identify current trends and developments
|
||||
- **Competitive Intelligence**: Analyze competitor content and strategies
|
||||
- **Comprehensive Research**: Gather information from multiple reliable sources with Google Search grounding
|
||||
- **Competitive Intelligence**: Identify content gaps and opportunities through competitor analysis
|
||||
- **Keyword Intelligence**: Discover primary, secondary, and long-tail keyword opportunities
|
||||
- **Content Angles**: AI-generated unique content angles for maximum engagement
|
||||
- **Time Efficiency**: Complete research in 30-60 seconds with intelligent caching
|
||||
|
||||
## Research Data Flow
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[User Input:<br/>Keywords + Topic] --> B[Phase 1: Research]
|
||||
B --> C{Cache Check}
|
||||
C -->|Hit| D[Return Cached<br/>Research]
|
||||
C -->|Miss| E[Google Search<br/>Grounding]
|
||||
E --> F[Source Extraction]
|
||||
F --> G[Keyword Analysis]
|
||||
F --> H[Competitor Analysis]
|
||||
F --> I[Content Angle<br/>Generation]
|
||||
G --> J[Research Output]
|
||||
H --> J
|
||||
I --> J
|
||||
D --> J
|
||||
|
||||
J --> K[Cache Storage]
|
||||
J --> L[Phase 2: Outline]
|
||||
|
||||
style B fill:#e8f5e8
|
||||
style E fill:#fff3e0
|
||||
style J fill:#e3f2fd
|
||||
style L fill:#fff3e0
|
||||
```
|
||||
|
||||
## Research Process
|
||||
|
||||
@@ -36,299 +62,325 @@ Research Integration is an AI-powered feature that automatically conducts compre
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Multi-Source Research
|
||||
### 2. Google Search Grounding (Gemini Integration)
|
||||
|
||||
#### Web Research
|
||||
- **Search Engines**: Google, Bing, and specialized search engines
|
||||
- **News Sources**: Current news and industry updates
|
||||
- **Blogs and Articles**: Industry blogs and expert articles
|
||||
- **Forums and Communities**: Reddit, Quora, and professional forums
|
||||
- **Social Media**: Twitter, LinkedIn, and industry discussions
|
||||
Phase 1 leverages Gemini's native Google Search grounding to access real-time web data with a single API call, eliminating the need for complex multi-source integrations.
|
||||
|
||||
#### Academic Sources
|
||||
- **Research Papers**: Academic journals and research publications
|
||||
- **Studies and Reports**: Industry studies and market research
|
||||
- **White Papers**: Technical and business white papers
|
||||
- **Case Studies**: Real-world examples and case studies
|
||||
- **Expert Opinions**: Industry expert insights and analysis
|
||||
#### Single API Call Efficiency
|
||||
- **One Request**: Comprehensive research in a single Gemini API call with Google Search grounding
|
||||
- **Live Web Data**: Real-time access to current information from the web
|
||||
- **No Multi-Source Setup**: Eliminates need for multiple API integrations
|
||||
- **Cost Effective**: Optimized token usage with focused research prompts
|
||||
- **Caching Intelligence**: Automatic cache storage for repeat keyword research
|
||||
|
||||
#### Industry Sources
|
||||
#### Research Sources (via Google Search)
|
||||
The research prompt instructs Gemini to gather information from:
|
||||
- **Current News**: Latest industry news and developments (2024-2025)
|
||||
- **Industry Reports**: Market research and industry analysis
|
||||
- **Company Publications**: Official company blogs and reports
|
||||
- **Professional Networks**: LinkedIn articles and professional content
|
||||
- **Trade Publications**: Industry-specific magazines and journals
|
||||
- **Conference Materials**: Industry conference presentations and papers
|
||||
- **Expert Articles**: Authoritative blogs and professional content
|
||||
- **Academic Sources**: Research papers and studies
|
||||
- **Case Studies**: Real-world examples and implementations
|
||||
- **Statistics**: Key data points and numerical insights
|
||||
- **Trends**: Current market trends and forecasts
|
||||
|
||||
### 3. Information Processing
|
||||
#### Google Search Grounding Example
|
||||
```python
|
||||
research_prompt = """
|
||||
Research the topic "AI in Digital Marketing" in the technology industry for digital marketers.
|
||||
|
||||
#### Data Collection
|
||||
- **Content Extraction**: Extract relevant information from sources
|
||||
- **Fact Identification**: Identify key facts, statistics, and claims
|
||||
- **Quote Collection**: Gather relevant quotes and expert opinions
|
||||
- **Trend Identification**: Identify current trends and patterns
|
||||
- **Gap Analysis**: Find information gaps and opportunities
|
||||
Provide comprehensive analysis including:
|
||||
1. Current trends and insights (2024-2025)
|
||||
2. Key statistics and data points with sources
|
||||
3. Industry expert opinions and quotes
|
||||
4. Recent developments and news
|
||||
5. Market analysis and forecasts
|
||||
6. Best practices and case studies
|
||||
7. Keyword analysis: primary, secondary, and long-tail opportunities
|
||||
8. Competitor analysis: top players and content gaps
|
||||
9. Content angle suggestions: 5 compelling angles for blog posts
|
||||
|
||||
#### Information Verification
|
||||
- **Fact Checking**: Verify facts against multiple sources
|
||||
- **Source Credibility**: Assess source authority and reliability
|
||||
- **Date Verification**: Ensure information is current and relevant
|
||||
- **Bias Detection**: Identify potential bias in sources
|
||||
- **Cross-Reference**: Cross-reference information across sources
|
||||
Focus on factual, up-to-date information from credible sources.
|
||||
"""
|
||||
```
|
||||
|
||||
## Research Features
|
||||
### 3. Competitor Analysis
|
||||
|
||||
### Real-Time Research
|
||||
The research phase automatically identifies competing content and discovers content gaps where your blog can stand out.
|
||||
|
||||
#### Live Data Access
|
||||
- **Current Information**: Access to real-time data and updates
|
||||
- **Trend Monitoring**: Track current trends and developments
|
||||
- **News Integration**: Include latest news and updates
|
||||
- **Social Media Monitoring**: Track social media discussions
|
||||
- **Market Data**: Access current market information
|
||||
#### Content Gap Identification
|
||||
- **Top Competitors**: Identifies the most authoritative content on your topic
|
||||
- **Coverage Analysis**: Maps what competitors have covered thoroughly vs. superficially
|
||||
- **Gap Opportunities**: Highlights underexplored angles and missing information
|
||||
- **Unique Positioning**: Suggests how to differentiate your content
|
||||
- **Competitive Advantages**: Identifies areas where you can exceed competitor quality
|
||||
|
||||
#### Dynamic Updates
|
||||
- **Content Freshness**: Ensure content includes latest information
|
||||
- **Trend Integration**: Incorporate current trends and developments
|
||||
- **News Relevance**: Include relevant recent news
|
||||
- **Market Updates**: Include current market conditions
|
||||
- **Expert Insights**: Access latest expert opinions
|
||||
#### Competitive Intelligence
|
||||
- **Content Depth**: Analyzes how thoroughly competitors cover topics
|
||||
- **Keyword Usage**: Identifies keyword strategies in competitor content
|
||||
- **Content Structure**: Evaluates how competitors organize information
|
||||
- **Engagement Patterns**: Notes what formats and angles work best
|
||||
- **Market Positioning**: Understands where competitors sit in the market
|
||||
|
||||
### Source Verification
|
||||
### 4. Keyword Intelligence
|
||||
|
||||
#### Credibility Assessment
|
||||
- **Domain Authority**: Check website authority and credibility
|
||||
- **Author Credentials**: Verify author expertise and credentials
|
||||
- **Publication Standards**: Assess publication quality and standards
|
||||
- **Peer Review**: Check for peer review and validation
|
||||
- **Fact-Checking**: Cross-reference with fact-checking organizations
|
||||
Phase 1 provides comprehensive keyword analysis to optimize your content for search engines.
|
||||
|
||||
#### Source Diversity
|
||||
- **Multiple Perspectives**: Include diverse viewpoints and opinions
|
||||
- **Source Types**: Mix different types of sources
|
||||
- **Geographic Diversity**: Include international sources
|
||||
- **Temporal Range**: Include both recent and historical sources
|
||||
- **Expertise Levels**: Include both expert and general sources
|
||||
#### Primary, Secondary & Long-Tail Keywords
|
||||
- **Primary Keywords**: Main topic keywords with highest search volume
|
||||
- **Secondary Keywords**: Supporting terms that reinforce the main topic
|
||||
- **Long-Tail Keywords**: Specific, less competitive phrases with high intent
|
||||
- **Semantic Keywords**: Related terms that search engines associate with your topic
|
||||
- **Search Intent**: Categorizes keywords by intent (informational, transactional, navigational)
|
||||
|
||||
### Fact Checking
|
||||
#### Keyword Clustering & Grouping
|
||||
- **Topic Clusters**: Groups related keywords for comprehensive coverage
|
||||
- **Thematic Organization**: Organizes keywords by content themes
|
||||
- **Density Recommendations**: Suggests optimal keyword usage throughout content
|
||||
- **Priority Ranking**: Identifies which keywords to prioritize
|
||||
- **Competition Analysis**: Assesses difficulty for ranking on each keyword
|
||||
|
||||
#### Automated Verification
|
||||
- **Claim Verification**: Verify specific claims and statements
|
||||
- **Statistical Validation**: Check statistics and numerical data
|
||||
- **Quote Verification**: Verify quotes and attributions
|
||||
- **Date Accuracy**: Ensure dates and timelines are correct
|
||||
- **Context Validation**: Verify context and interpretation
|
||||
### 5. Content Angle Generation
|
||||
|
||||
#### Manual Review
|
||||
- **Expert Review**: Human expert review of critical information
|
||||
- **Quality Assurance**: Manual quality checks and validation
|
||||
- **Bias Assessment**: Human assessment of potential bias
|
||||
- **Context Analysis**: Human analysis of context and interpretation
|
||||
- **Final Validation**: Final human validation of research quality
|
||||
AI generates unique content angles that make your blog stand out and engage your audience.
|
||||
|
||||
## Research Output
|
||||
#### AI-Generated Angle Suggestions
|
||||
- **5 Unique Angles**: Provides multiple distinct approaches to your topic
|
||||
- **Trending Topics**: Identifies currently popular angles and discussions
|
||||
- **Audience Pain Points**: Maps audience challenges to content angles
|
||||
- **Viral Potential**: Assesses which angles have high shareability
|
||||
- **Expert Opinions**: Synthesizes industry expert viewpoints into angles
|
||||
|
||||
### Organized Information
|
||||
#### Content Angle Example
|
||||
For a topic like "AI in Marketing," research might suggest:
|
||||
1. **Case Study Angle**: "10 Marketing Agencies Using AI to Double ROI"
|
||||
2. **Practical Guide Angle**: "Implementing AI Marketing Tools in 2025: A Step-by-Step Roadmap"
|
||||
3. **Trend Analysis Angle**: "The Future of AI Marketing: What Industry Leaders Predict"
|
||||
4. **Problem-Solution Angle**: "Common AI Marketing Failures and How to Avoid Them"
|
||||
5. **Debunking Angle**: "AI Marketing Myths Debunked: What Actually Works in 2025"
|
||||
|
||||
#### Structured Data
|
||||
- **Key Facts**: Organized list of key facts and information
|
||||
- **Statistics**: Relevant statistics and numerical data
|
||||
- **Quotes**: Expert quotes and opinions
|
||||
- **Trends**: Current trends and developments
|
||||
- **Sources**: Complete source list with citations
|
||||
### 6. Information Processing
|
||||
|
||||
#### Research Summary
|
||||
- **Executive Summary**: High-level overview of research findings
|
||||
- **Key Insights**: Main insights and discoveries
|
||||
- **Trend Analysis**: Analysis of current trends
|
||||
- **Gap Identification**: Information gaps and opportunities
|
||||
- **Recommendations**: Research-based recommendations
|
||||
#### Data Collection & Extraction
|
||||
- **Source Extraction**: Automatically extracts 10-20 credible sources from Google Search
|
||||
- **Fact Identification**: Identifies key facts, statistics, and claims with citations
|
||||
- **Quote Collection**: Gathers relevant expert quotes with attribution
|
||||
- **Trend Identification**: Highlights current trends and patterns
|
||||
- **Search Query Tracking**: Tracks AI-generated search queries for transparency
|
||||
|
||||
### Source Citations
|
||||
#### Source Credibility & Verification
|
||||
- **Automatic Citation**: Extracts source URLs, titles, and metadata for proper attribution
|
||||
- **Grounding Metadata**: Includes detailed grounding support scores and chunks
|
||||
- **Source Diversity**: Ensures mix of authoritative sources (academic, industry, news)
|
||||
- **Credibility Scoring**: Evaluates source authority and reliability
|
||||
- **Cross-Reference**: Cross-references key facts across multiple sources
|
||||
|
||||
#### Citation Format
|
||||
- **APA Style**: Academic citation format
|
||||
- **MLA Style**: Modern Language Association format
|
||||
- **Chicago Style**: Chicago Manual of Style format
|
||||
- **Custom Format**: Customizable citation format
|
||||
- **Hyperlinks**: Direct links to source materials
|
||||
## Research Output Structure
|
||||
|
||||
#### Source Information
|
||||
- **Author Details**: Author name, credentials, and affiliation
|
||||
- **Publication Information**: Publication name, date, and details
|
||||
- **URL and Access**: Direct links and access information
|
||||
- **Credibility Score**: Source credibility assessment
|
||||
- **Last Updated**: Last update or verification date
|
||||
### Comprehensive Research Results
|
||||
|
||||
## Integration with Content Creation
|
||||
Phase 1 returns a complete research package that feeds into all subsequent phases:
|
||||
|
||||
### Content Planning
|
||||
#### Structured Data Package
|
||||
- **Sources**: 10-20 credible research sources with full metadata
|
||||
- **Keyword Analysis**: Primary, secondary, long-tail, and semantic keywords
|
||||
- **Competitor Analysis**: Top competing content and identified gaps
|
||||
- **Content Angles**: 5 unique, AI-generated content approaches
|
||||
- **Search Queries**: AI-generated search terms for transparency
|
||||
- **Grounding Metadata**: Detailed grounding support scores and chunks
|
||||
|
||||
#### Research-Informed Planning
|
||||
- **Topic Development**: Develop topics based on research insights
|
||||
- **Content Structure**: Structure content based on research findings
|
||||
- **Key Points**: Identify key points from research
|
||||
- **Supporting Evidence**: Gather supporting evidence and examples
|
||||
- **Expert Opinions**: Include relevant expert opinions
|
||||
#### Research Summary Example
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"sources": [
|
||||
{
|
||||
"url": "https://example.com/research",
|
||||
"title": "AI Marketing Trends 2025",
|
||||
"credibility_score": 0.92
|
||||
}
|
||||
],
|
||||
"keyword_analysis": {
|
||||
"primary": ["AI marketing", "artificial intelligence digital marketing"],
|
||||
"secondary": ["machine learning marketing", "automated advertising"],
|
||||
"long_tail": ["how to implement AI marketing tools"],
|
||||
"search_intent": "informational"
|
||||
},
|
||||
"competitor_analysis": {
|
||||
"top_competitors": [...],
|
||||
"content_gaps": ["practical implementation guides", "cost-benefit analysis"]
|
||||
},
|
||||
"suggested_angles": [
|
||||
"10 Marketing Agencies Using AI to Double ROI",
|
||||
"Implementing AI Marketing Tools: A Step-by-Step Roadmap"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Content Strategy
|
||||
- **Audience Insights**: Understand audience based on research
|
||||
- **Competitive Analysis**: Analyze competitor content and strategies
|
||||
- **Trend Integration**: Incorporate current trends and developments
|
||||
- **Gap Opportunities**: Identify content gaps and opportunities
|
||||
- **Value Proposition**: Develop unique value propositions
|
||||
## Use Cases for Different Audiences
|
||||
|
||||
### Content Enhancement
|
||||
### For Technical Content Writers
|
||||
**Scenario**: Writing a technical deep-dive on "React Performance Optimization"
|
||||
|
||||
#### Evidence-Based Content
|
||||
- **Factual Accuracy**: Ensure all facts are accurate and verified
|
||||
- **Statistical Support**: Support claims with relevant statistics
|
||||
- **Expert Validation**: Include expert opinions and validation
|
||||
- **Case Studies**: Include relevant case studies and examples
|
||||
- **Trend Analysis**: Incorporate current trend analysis
|
||||
**Phase 1 Delivers**:
|
||||
- Latest React documentation updates and best practices
|
||||
- GitHub discussions and Stack Overflow solutions for optimization challenges
|
||||
- Academic research on frontend performance optimization
|
||||
- Real-world case studies from major tech companies
|
||||
- Technical keyword opportunities: "React performance hooks", "memoization strategies"
|
||||
|
||||
#### Credibility Building
|
||||
- **Source Attribution**: Proper attribution of all sources
|
||||
- **Expert Quotes**: Include relevant expert quotes
|
||||
- **Data Visualization**: Present data in clear, visual formats
|
||||
- **Transparency**: Show research process and methodology
|
||||
- **Quality Assurance**: Maintain high quality standards
|
||||
**Value**: Eliminates hours of manual research across GitHub, documentation, and forums
|
||||
|
||||
## Research Tools and Sources
|
||||
### For Solopreneurs
|
||||
**Scenario**: Creating content on "Starting an E-commerce Business in 2025"
|
||||
|
||||
### Web Research Tools
|
||||
**Phase 1 Delivers**:
|
||||
- Current e-commerce market trends and statistics
|
||||
- Competitor analysis of top e-commerce success stories
|
||||
- Content gap: most content focuses on "how to start" but lacks "common pitfalls"
|
||||
- Unique angle: "The 5 Mistakes That Kill 90% of New E-commerce Businesses"
|
||||
- Long-tail keywords: "start ecommerce business 2025", "ecommerce business ideas"
|
||||
|
||||
#### Search Engines
|
||||
- **Google Search**: Comprehensive web search
|
||||
- **Bing Search**: Alternative search engine
|
||||
- **DuckDuckGo**: Privacy-focused search
|
||||
- **Specialized Search**: Industry-specific search engines
|
||||
- **Academic Search**: Academic and research databases
|
||||
**Value**: Provides business intelligence without expensive consultants
|
||||
|
||||
#### Research Platforms
|
||||
- **Google Scholar**: Academic research and papers
|
||||
- **ResearchGate**: Academic network and research
|
||||
- **JSTOR**: Academic journal database
|
||||
- **PubMed**: Medical and scientific research
|
||||
- **IEEE Xplore**: Technical and engineering research
|
||||
### For Digital Marketing & SEO Professionals
|
||||
**Scenario**: Content strategy for "Local SEO Best Practices"
|
||||
|
||||
### Industry Sources
|
||||
**Phase 1 Delivers**:
|
||||
- Competitor analysis of top-ranking local SEO content
|
||||
- Keyword gaps: competitors missing "Google Business Profile optimization"
|
||||
- Trending angles: "Voice search local optimization" and "AI-powered local listings"
|
||||
- Data-backed insights: "73% of local searches result in store visits"
|
||||
- Content opportunity: "Local SEO Audit Template" (high search, low competition)
|
||||
|
||||
#### Market Research
|
||||
- **Statista**: Statistical data and market research
|
||||
- **IBISWorld**: Industry research and analysis
|
||||
- **McKinsey**: Business and industry insights
|
||||
- **Deloitte**: Professional services research
|
||||
- **PwC**: Business and industry analysis
|
||||
**Value**: Delivers competitive intelligence and keyword strategy in one research pass
|
||||
|
||||
#### News and Media
|
||||
- **Reuters**: International news and analysis
|
||||
- **Bloomberg**: Business and financial news
|
||||
- **TechCrunch**: Technology news and analysis
|
||||
- **Harvard Business Review**: Business insights and analysis
|
||||
- **MIT Technology Review**: Technology and innovation news
|
||||
## Performance & Caching
|
||||
|
||||
### Intelligent Caching System
|
||||
|
||||
Phase 1 implements a dual-layer caching strategy to optimize performance and reduce costs.
|
||||
|
||||
#### Cache Storage
|
||||
- **Persistent Cache**: SQLite database stores research results for exact keyword matches
|
||||
- **Memory Cache**: In-process cache for faster repeated access within a session
|
||||
- **Cache Key**: Based on exact keyword match, industry, and target audience
|
||||
- **Cache Duration**: Results stored indefinitely until invalidated
|
||||
|
||||
#### Cache Benefits
|
||||
- **Cost Reduction**: Avoids redundant API calls for same topics
|
||||
- **Speed**: Instant results for cached research (0-5 seconds vs. 30-60 seconds)
|
||||
- **Consistency**: Ensures reproducible research results for same queries
|
||||
- **Transparency**: Progress messages indicate cache hits: "✅ Using cached research"
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
**Typical Research Timing**:
|
||||
- **Cache Hit**: 0-5 seconds (instant return)
|
||||
- **Fresh Research**: 30-60 seconds (Google Search + AI processing)
|
||||
- **Sources Found**: 10-20 credible sources per research
|
||||
- **Search Queries**: 5-10 AI-generated search terms tracked
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Research Quality
|
||||
### Effective Research Setup
|
||||
|
||||
#### Source Selection
|
||||
1. **Authority**: Choose authoritative and credible sources
|
||||
2. **Recency**: Prefer recent and up-to-date information
|
||||
3. **Relevance**: Ensure sources are relevant to your topic
|
||||
4. **Diversity**: Include diverse perspectives and sources
|
||||
5. **Verification**: Cross-reference information across sources
|
||||
#### Keyword Strategy
|
||||
1. **Be Specific**: Use 3-5 focused keywords rather than broad topics
|
||||
2. **Industry Context**: Always specify industry for better context
|
||||
3. **Audience Definition**: Define target audience clearly for tailored research
|
||||
4. **Topic Clarity**: Provide a clear, concise topic description
|
||||
5. **Word Count Target**: Set realistic word count goals (1000-3000 words optimal)
|
||||
|
||||
#### Information Processing
|
||||
1. **Accuracy**: Verify all facts and claims
|
||||
2. **Context**: Understand context and interpretation
|
||||
3. **Bias Awareness**: Be aware of potential bias
|
||||
4. **Completeness**: Ensure comprehensive coverage
|
||||
5. **Quality**: Maintain high quality standards
|
||||
#### Research Quality Optimization
|
||||
1. **Review Sources**: Always review the returned sources for credibility
|
||||
2. **Use Content Angles**: Leverage AI-generated angles for unique positioning
|
||||
3. **Explore Competitor Gaps**: Focus on content gaps for competitive advantage
|
||||
4. **Keyword Variety**: Review all keyword types (primary, secondary, long-tail)
|
||||
5. **Leverage Caching**: Reuse research for related topics to save time and cost
|
||||
|
||||
### Content Integration
|
||||
### Research-to-Content Pipeline
|
||||
|
||||
#### Research Application
|
||||
1. **Relevance**: Use research that's relevant to your audience
|
||||
2. **Balance**: Balance different perspectives and opinions
|
||||
3. **Clarity**: Present research findings clearly
|
||||
4. **Attribution**: Properly attribute all sources
|
||||
5. **Value**: Add value through research insights
|
||||
#### Phase 1 to Phase 2 Transition
|
||||
1. **Validate Research**: Ensure research has 10+ credible sources before proceeding
|
||||
2. **Review Angles**: Select compelling content angles for outline inspiration
|
||||
3. **Check Keywords**: Verify keyword analysis aligns with your SEO goals
|
||||
4. **Analyze Gaps**: Use competitor analysis to inform unique content positioning
|
||||
5. **Source Quality**: Confirm grounding metadata shows high credibility scores (0.8+)
|
||||
|
||||
#### Quality Assurance
|
||||
1. **Fact Checking**: Verify all facts and claims
|
||||
2. **Source Review**: Review and validate all sources
|
||||
3. **Expert Input**: Seek expert input when needed
|
||||
4. **Peer Review**: Get peer review of research quality
|
||||
5. **Continuous Improvement**: Continuously improve research process
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Research
|
||||
|
||||
#### Research Parameters
|
||||
- **Custom Sources**: Specify custom source preferences
|
||||
- **Research Depth**: Adjust research depth and scope
|
||||
- **Language Settings**: Set research language preferences
|
||||
- **Date Ranges**: Specify date ranges for research
|
||||
- **Geographic Focus**: Set geographic focus for research
|
||||
|
||||
#### Research Filters
|
||||
- **Source Types**: Filter by source types and categories
|
||||
- **Credibility Thresholds**: Set minimum credibility requirements
|
||||
- **Date Filters**: Filter by publication date
|
||||
- **Language Filters**: Filter by language
|
||||
- **Topic Filters**: Filter by topic relevance
|
||||
|
||||
### Research Analytics
|
||||
|
||||
#### Performance Tracking
|
||||
- **Research Quality**: Track research quality metrics
|
||||
- **Source Performance**: Monitor source performance
|
||||
- **Accuracy Rates**: Track fact-checking accuracy
|
||||
- **User Satisfaction**: Monitor user satisfaction with research
|
||||
- **Improvement Areas**: Identify areas for improvement
|
||||
|
||||
#### Research Insights
|
||||
- **Trend Analysis**: Analyze research trends and patterns
|
||||
- **Source Analysis**: Analyze source performance and quality
|
||||
- **Content Impact**: Measure impact of research on content
|
||||
- **Audience Engagement**: Track audience engagement with research
|
||||
- **ROI Analysis**: Analyze return on research investment
|
||||
#### Research Output Utilization
|
||||
1. **Source Mapping**: Use sources strategically across different sections
|
||||
2. **Keyword Integration**: Naturally integrate primary and secondary keywords
|
||||
3. **Angles to Sections**: Transform content angles into distinct content sections
|
||||
4. **Gaps to Value**: Convert content gaps into unique selling propositions
|
||||
5. **Trend Integration**: Weave current trends naturally throughout content
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
### Common Issues & Solutions
|
||||
|
||||
#### Research Quality
|
||||
- **Insufficient Sources**: Add more diverse sources
|
||||
- **Outdated Information**: Update research with current information
|
||||
- **Bias Detection**: Address potential bias in sources
|
||||
- **Fact Verification**: Improve fact-checking process
|
||||
- **Source Credibility**: Improve source selection criteria
|
||||
#### Low-Quality Research Results
|
||||
**Problem**: Research returns fewer than 10 sources or low credibility scores
|
||||
|
||||
#### Technical Issues
|
||||
- **API Connectivity**: Resolve API connection issues
|
||||
- **Data Processing**: Fix data processing problems
|
||||
- **Source Access**: Resolve source access issues
|
||||
- **Performance Issues**: Address performance concerns
|
||||
- **Integration Problems**: Fix integration issues
|
||||
**Solutions**:
|
||||
- **Refine Keywords**: Use more specific, focused keywords
|
||||
- **Expand Topic**: Broaden topic slightly to increase source pool
|
||||
- **Adjust Industry**: Ensure industry classification is accurate
|
||||
- **Check Cache**: Clear cache if you're getting stale results
|
||||
- **Retry Research**: Google Search grounding may need a second attempt
|
||||
|
||||
#### Insufficient Keyword Analysis
|
||||
**Problem**: Limited keyword variety or missing long-tail opportunities
|
||||
|
||||
**Solutions**:
|
||||
- **Add Topic Context**: Provide more detailed topic description
|
||||
- **Specify Audience**: Better audience definition improves keyword targeting
|
||||
- **Increase Word Count**: Target 2000+ words for richer keyword analysis
|
||||
- **Review Persona Settings**: Industry and audience persona affects keyword discovery
|
||||
|
||||
#### Missing Competitor Data
|
||||
**Problem**: Competitor analysis lacks depth or opportunities
|
||||
|
||||
**Solutions**:
|
||||
- **Use Specific Keywords**: More targeted keywords reveal better competitors
|
||||
- **Expand Industry Context**: Broad industry understanding improves competitive mapping
|
||||
- **Review Content Angles**: Angles often highlight what competitors are NOT doing
|
||||
- **Manual Review**: Top sources list shows main competitors worth reviewing
|
||||
|
||||
#### Cache Not Working
|
||||
**Problem**: Research taking full time even for duplicate keywords
|
||||
|
||||
**Solutions**:
|
||||
- **Check Exact Match**: Keywords, industry, and audience must match exactly
|
||||
- **Verify Cache**: Check if persistent cache is enabled
|
||||
- **Clear and Retry**: Sometimes clearing cache helps if data is corrupted
|
||||
- **Check Logs**: Look for cache hit/miss messages in progress updates
|
||||
|
||||
### Getting Help
|
||||
|
||||
#### Support Resources
|
||||
- **Documentation**: Review research integration documentation
|
||||
- **Tutorials**: Watch research feature tutorials
|
||||
- **Best Practices**: Follow research best practices
|
||||
- **Community**: Join user community discussions
|
||||
- **Support**: Contact technical support
|
||||
- **Workflow Guide**: [Complete 6-phase walkthrough](workflow-guide.md)
|
||||
- **API Reference**: [Research API endpoints](api-reference.md)
|
||||
- **Implementation Spec**: [Technical architecture](implementation-spec.md)
|
||||
- **Best Practices**: [Advanced optimization tips](../../guides/best-practices.md)
|
||||
|
||||
#### Optimization Tips
|
||||
- **Settings Review**: Regularly review research settings
|
||||
- **Source Management**: Maintain source quality and diversity
|
||||
- **Quality Monitoring**: Monitor research quality continuously
|
||||
- **Performance Tracking**: Track research performance metrics
|
||||
- **Continuous Improvement**: Continuously improve research process
|
||||
#### Performance Optimization
|
||||
- **Use Caching**: Leverage intelligent caching for repeat research
|
||||
- **Keyword Precision**: More specific keywords yield better results
|
||||
- **Industry Context**: Always provide industry for better data quality
|
||||
- **Monitor Progress**: Review progress messages for efficiency insights
|
||||
- **Batch Research**: Plan multiple blogs to maximize cache benefits
|
||||
|
||||
---
|
||||
|
||||
*Ready to enhance your content with comprehensive research? [Start with our First Steps Guide](../../getting-started/first-steps.md) and [Explore Blog Writer Features](overview.md) to begin creating well-researched, authoritative content!*
|
||||
## Next Steps
|
||||
|
||||
Now that you understand Phase 1: Research & Strategy, move to the next phase:
|
||||
|
||||
- **[Phase 2: Intelligent Outline](workflow-guide.md#phase-2-intelligent-outline)** - Transform research into structured content plans
|
||||
- **[Complete Workflow Guide](workflow-guide.md)** - End-to-end 6-phase walkthrough
|
||||
- **[Blog Writer Overview](overview.md)** - Overview of all 6 phases
|
||||
- **[Getting Started Guide](../../getting-started/quick-start.md)** - Quick start for new users
|
||||
|
||||
---
|
||||
|
||||
*Ready to leverage Phase 1 research capabilities? Check out the [Workflow Guide](workflow-guide.md) to see how research flows into outline generation and beyond!*
|
||||
|
||||
@@ -1,343 +1,478 @@
|
||||
# SEO Analysis
|
||||
# SEO Analysis & Optimization (Phase 4 & 5)
|
||||
|
||||
ALwrity's Blog Writer includes comprehensive SEO analysis capabilities that automatically optimize your content for search engines, improve readability, and enhance your content's search visibility.
|
||||
ALwrity's Blog Writer includes comprehensive SEO analysis and metadata generation capabilities across Phases 4 and 5, automatically optimizing your content for search engines and preparing it for publication across platforms.
|
||||
|
||||
## What is SEO Analysis?
|
||||
## Overview
|
||||
|
||||
SEO Analysis is an AI-powered feature that evaluates your blog content for search engine optimization, providing detailed insights, recommendations, and automated optimizations to improve your content's search ranking and visibility.
|
||||
SEO optimization in the Blog Writer happens in two complementary phases:
|
||||
- **Phase 4: SEO Analysis** - Comprehensive scoring, recommendations, and AI-powered content refinement
|
||||
- **Phase 5: SEO Metadata** - Complete metadata generation including Open Graph, Twitter Cards, and Schema.org markup
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- **Search Optimization**: Optimize content for search engines
|
||||
- **Keyword Analysis**: Analyze and optimize keyword usage
|
||||
- **Readability Enhancement**: Improve content readability and user experience
|
||||
- **Technical SEO**: Ensure proper technical SEO implementation
|
||||
- **Performance Insights**: Track and improve SEO performance
|
||||
#### Phase 4: SEO Analysis
|
||||
- **Multi-Dimensional Scoring**: Comprehensive SEO evaluation across 5 key categories
|
||||
- **Actionable Recommendations**: Priority-ranked improvement suggestions with specific fixes
|
||||
- **AI-Powered Refinement**: One-click "Apply Recommendations" for instant optimization
|
||||
- **Parallel Processing**: Fast analysis using parallel non-AI analyzers plus AI insights
|
||||
- **Performance Tracking**: Track SEO improvements and measure impact
|
||||
|
||||
## SEO Analysis Process
|
||||
#### Phase 5: SEO Metadata
|
||||
- **Comprehensive Metadata**: Complete SEO metadata package in 2 AI calls maximum
|
||||
- **Social Optimization**: Open Graph and Twitter Cards for rich social previews
|
||||
- **Structured Data**: Schema.org markup for enhanced search results and rich snippets
|
||||
- **Multi-Format Export**: Ready-to-use formats for WordPress, Wix, and custom platforms
|
||||
- **Platform Integration**: One-click copy and direct platform publishing support
|
||||
|
||||
### 1. Content Analysis
|
||||
## Phase 4: SEO Analysis
|
||||
|
||||
Phase 4 provides comprehensive SEO evaluation with actionable recommendations and AI-powered content refinement.
|
||||
|
||||
### Parallel Processing Architecture
|
||||
|
||||
Phase 4 uses a sophisticated parallel processing approach for speed and accuracy:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Blog Content] --> B[Phase 4: SEO Analysis]
|
||||
B --> C[Parallel Non-AI Analyzers]
|
||||
C --> D[Content Structure]
|
||||
C --> E[Keyword Usage]
|
||||
C --> F[Readability]
|
||||
C --> G[Content Quality]
|
||||
C --> H[Heading Structure]
|
||||
|
||||
D --> I[SEO Results]
|
||||
E --> I
|
||||
F --> I
|
||||
G --> I
|
||||
H --> I
|
||||
|
||||
I --> J[Single AI Analysis]
|
||||
J --> K[Actionable Recommendations]
|
||||
K --> L[Apply Recommendations]
|
||||
L --> M[Refined Content]
|
||||
|
||||
style A fill:#e3f2fd
|
||||
style B fill:#f1f8e9
|
||||
style C fill:#fff3e0
|
||||
style I fill:#e8f5e8
|
||||
style L fill:#fce4ec
|
||||
style M fill:#e1f5fe
|
||||
```
|
||||
|
||||
### Multi-Dimensional SEO Scoring
|
||||
|
||||
Phase 4 evaluates your content across 5 key categories:
|
||||
|
||||
#### Overall SEO Score
|
||||
- **Composite Rating**: Overall score (0-100) based on weighted category scores
|
||||
- **Grade Assignment**: Automatically assigns grades (Excellent/Good/Needs Improvement)
|
||||
- **Trend Tracking**: Compares to previous analysis to track improvements
|
||||
- **Visual Feedback**: Color-coded UI provides instant visual assessment
|
||||
|
||||
#### Category Breakdown
|
||||
- **Structure Score**: Heading hierarchy, content organization, section balance
|
||||
- **Keywords Score**: Keyword density, placement, variation, long-tail usage
|
||||
- **Readability Score**: Reading level, sentence complexity, clarity assessment
|
||||
- **Quality Score**: Content depth, engagement potential, value delivery
|
||||
- **Headings Score**: H1-H3 distribution, keyword integration in headings
|
||||
|
||||
### Actionable Recommendations
|
||||
|
||||
Phase 4 generates specific, priority-ranked recommendations for improvement.
|
||||
|
||||
#### Recommendation Categories
|
||||
- **High Priority**: Critical SEO issues impacting search visibility
|
||||
- **Medium Priority**: Significant improvements that boost rankings
|
||||
- **Low Priority**: Nice-to-have optimizations for fine-tuning
|
||||
|
||||
#### Example Recommendations
|
||||
1. **Structure**: "Add more H2 subheadings to improve content scannability and keyword distribution"
|
||||
2. **Keywords**: "Increase primary keyword density from 0.8% to 1.5% for optimal SEO performance"
|
||||
3. **Readability**: "Simplify complex sentences; aim for average 15-20 words per sentence"
|
||||
4. **Content**: "Add more specific examples and case studies to support key arguments"
|
||||
5. **Meta**: "Reduce meta description to 155 characters for better search result display"
|
||||
|
||||
### AI-Powered Content Refinement
|
||||
|
||||
The "Apply Recommendations" feature uses AI to automatically improve your content based on SEO analysis.
|
||||
|
||||
#### Intelligent Rewriting
|
||||
- **Smart Application**: Applies recommendations while preserving your original intent
|
||||
- **Natural Integration**: Optimizes keywords and structure without sounding forced
|
||||
- **Context Preservation**: Maintains research accuracy and source alignment
|
||||
- **Quality Maintenance**: Ensures readability while improving SEO metrics
|
||||
|
||||
#### Application Process
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Current Content] --> B[SEO Recommendations]
|
||||
B --> C[AI Prompt Construction]
|
||||
C --> D[LLM Text Generation]
|
||||
D --> E[Normalization & Validation]
|
||||
E --> F[Optimized Content]
|
||||
|
||||
style A fill:#e3f2fd
|
||||
style B fill:#fff3e0
|
||||
style D fill:#f1f8e9
|
||||
style F fill:#e8f5e8
|
||||
```
|
||||
|
||||
### Content Analysis Process
|
||||
|
||||
#### Initial Assessment
|
||||
- **Content Structure**: Analyze heading hierarchy and content organization
|
||||
- **Keyword Density**: Check keyword usage and density
|
||||
- **Content Length**: Evaluate content length and depth
|
||||
- **Readability**: Assess content readability and user experience
|
||||
- **Technical Elements**: Check technical SEO elements
|
||||
- **Content Structure**: Analyzes heading hierarchy, paragraph distribution, list usage
|
||||
- **Keyword Distribution**: Maps keyword density and placement across sections
|
||||
- **Readability Metrics**: Calculates Flesch Reading Ease, sentence length, complexity
|
||||
- **Quality Indicators**: Evaluates depth, engagement potential, value delivery
|
||||
- **Technical Elements**: Checks heading structure, meta elements, content length
|
||||
|
||||
#### Analysis Parameters
|
||||
#### Parallel Analysis Details
|
||||
Each analyzer processes content independently:
|
||||
- **ContentAnalyzer**: Structure, organization, section balance
|
||||
- **KeywordAnalyzer**: Density, placement, variation, semantic coverage
|
||||
- **ReadabilityAnalyzer**: Reading level, sentence complexity, word choice
|
||||
- **QualityAnalyzer**: Depth, engagement, value, completeness
|
||||
- **HeadingAnalyzer**: Hierarchy, distribution, keyword integration
|
||||
|
||||
Results are combined with AI insights for comprehensive recommendations.
|
||||
|
||||
## Phase 5: SEO Metadata Generation
|
||||
|
||||
Phase 5 generates comprehensive SEO metadata in maximum 2 AI calls, creating a complete optimization package ready for publication.
|
||||
|
||||
### Efficient Two-Call Architecture
|
||||
|
||||
Phase 5 minimizes AI calls for cost efficiency while delivering comprehensive metadata:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Blog Content + SEO Analysis] --> B[Phase 5: Metadata Generation]
|
||||
B --> C{Call 1: Core Metadata}
|
||||
C --> D[SEO Title]
|
||||
C --> E[Meta Description]
|
||||
C --> F[URL Slug]
|
||||
C --> G[Tags & Categories]
|
||||
C --> H[Reading Time]
|
||||
|
||||
D --> I{Call 2: Social Metadata}
|
||||
E --> I
|
||||
F --> I
|
||||
G --> I
|
||||
H --> I
|
||||
|
||||
I --> J[Open Graph Tags]
|
||||
I --> K[Twitter Cards]
|
||||
I --> L[Schema.org JSON-LD]
|
||||
|
||||
J --> M[Complete Metadata Package]
|
||||
K --> M
|
||||
L --> M
|
||||
|
||||
style A fill:#e3f2fd
|
||||
style B fill:#e0f2f1
|
||||
style C fill:#fff3e0
|
||||
style I fill:#fce4ec
|
||||
style M fill:#e8f5e8
|
||||
```
|
||||
|
||||
### Core Metadata Generation
|
||||
|
||||
#### SEO-Optimized Elements
|
||||
- **SEO Title** (50-60 chars): Front-loaded primary keyword, compelling, click-worthy
|
||||
- **Meta Description** (150-160 chars): Keyword-rich with strong CTA in first 120 chars
|
||||
- **URL Slug**: Clean, hyphenated, 3-5 words with primary keyword
|
||||
- **Blog Tags** (5-8): Mix of primary, semantic, and long-tail keywords
|
||||
- **Blog Categories** (2-3): Industry-standard classification
|
||||
- **Social Hashtags** (5-10): Industry-specific with trending terms
|
||||
- **Reading Time**: Calculated from word count (200 words/minute)
|
||||
- **Focus Keyword**: Main SEO keyword selection
|
||||
|
||||
#### Metadata Personalization
|
||||
Metadata is dynamically tailored based on:
|
||||
- Research keywords and search intent
|
||||
- Target audience and industry
|
||||
- SEO analysis recommendations
|
||||
- Blog content structure and outline
|
||||
- Tone and writing style preferences
|
||||
|
||||
### Social Media Optimization
|
||||
|
||||
#### Open Graph Tags
|
||||
- **og:title**: Optimized for social sharing
|
||||
- **og:description**: Compelling social preview text
|
||||
- **og:image**: Recommended image dimensions and sources
|
||||
- **og:type**: Article/blog classification
|
||||
- **og:url**: Canonical URL reference
|
||||
|
||||
#### Twitter Cards
|
||||
- **twitter:card**: Summary card with large image support
|
||||
- **twitter:title**: Concise, engaging headline
|
||||
- **twitter:description**: Twitter-optimized summary
|
||||
- **twitter:image**: Twitter-specific image optimization
|
||||
- **twitter:site**: Website Twitter handle integration
|
||||
|
||||
### Structured Data (Schema.org)
|
||||
|
||||
#### Article Schema
|
||||
```json
|
||||
{
|
||||
"content": "Your blog post content here...",
|
||||
"target_keywords": ["primary keyword", "secondary keyword"],
|
||||
"competitor_urls": ["https://competitor1.com", "https://competitor2.com"],
|
||||
"analysis_depth": "comprehensive",
|
||||
"optimization_goals": ["rankings", "traffic", "engagement"]
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
"headline": "SEO-optimized title",
|
||||
"description": "Meta description",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "Your Brand"
|
||||
},
|
||||
"datePublished": "2025-01-20",
|
||||
"dateModified": "2025-01-20",
|
||||
"mainEntityOfPage": {
|
||||
"@type": "WebPage"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Keyword Analysis
|
||||
#### Additional Schema Types
|
||||
- **Organization Markup**: Brand and publisher information
|
||||
- **Breadcrumb Schema**: Navigation structure for rich snippets
|
||||
- **FAQ Schema**: Q&A structured data for featured snippets
|
||||
- **Review Schema**: Ratings and review markup
|
||||
|
||||
#### Primary Keywords
|
||||
- **Keyword Density**: Analyze primary keyword density
|
||||
- **Keyword Placement**: Check keyword placement and distribution
|
||||
- **Keyword Variations**: Identify keyword variations and synonyms
|
||||
- **Long-Tail Keywords**: Analyze long-tail keyword usage
|
||||
- **Semantic Keywords**: Check semantic keyword integration
|
||||
### Multi-Format Export
|
||||
|
||||
#### Secondary Keywords
|
||||
- **Related Terms**: Identify related terms and phrases
|
||||
- **LSI Keywords**: Check latent semantic indexing keywords
|
||||
- **Contextual Keywords**: Analyze contextual keyword usage
|
||||
- **Industry Terms**: Include industry-specific terminology
|
||||
- **User Intent**: Match keywords to user search intent
|
||||
Phase 5 outputs metadata in multiple formats for different platforms:
|
||||
|
||||
### 3. Content Optimization
|
||||
#### HTML Meta Tags
|
||||
```html
|
||||
<meta property="og:title" content="AI in Medical Diagnosis: Transforming Healthcare">
|
||||
<meta name="description" content="Discover how AI is revolutionizing medical diagnosis...">
|
||||
<meta name="keywords" content="AI healthcare, medical diagnosis, healthcare technology">
|
||||
```
|
||||
|
||||
#### Structure Analysis
|
||||
- **Heading Hierarchy**: Check H1, H2, H3 structure
|
||||
- **Paragraph Length**: Analyze paragraph length and structure
|
||||
- **List Usage**: Check for bullet points and numbered lists
|
||||
- **Content Flow**: Analyze content flow and organization
|
||||
- **Section Balance**: Ensure balanced content sections
|
||||
#### JSON-LD Structured Data
|
||||
Ready-to-paste structured data for search engines
|
||||
|
||||
#### Readability Assessment
|
||||
- **Reading Level**: Assess content reading level
|
||||
- **Sentence Length**: Analyze sentence length and complexity
|
||||
- **Word Choice**: Check word choice and vocabulary
|
||||
- **Clarity**: Assess content clarity and understanding
|
||||
- **Engagement**: Evaluate content engagement potential
|
||||
#### WordPress Export
|
||||
WordPress-specific format with Yoast SEO compatibility
|
||||
|
||||
## SEO Analysis Features
|
||||
|
||||
### Keyword Optimization
|
||||
|
||||
#### Keyword Research
|
||||
- **Primary Keywords**: Identify main target keywords
|
||||
- **Secondary Keywords**: Find supporting keywords
|
||||
- **Long-Tail Keywords**: Discover specific, less competitive phrases
|
||||
- **LSI Keywords**: Find semantically related terms
|
||||
- **Competitor Keywords**: Analyze competitor keyword usage
|
||||
|
||||
#### Keyword Implementation
|
||||
- **Title Optimization**: Optimize title tags for keywords
|
||||
- **Meta Description**: Create keyword-rich meta descriptions
|
||||
- **Heading Tags**: Optimize heading tags for keywords
|
||||
- **Content Integration**: Naturally integrate keywords into content
|
||||
- **Internal Linking**: Use keywords in internal links
|
||||
|
||||
### Content Structure
|
||||
|
||||
#### Heading Optimization
|
||||
- **H1 Tag**: Single, keyword-rich H1 tag
|
||||
- **H2 Tags**: Logical H2 tag structure
|
||||
- **H3 Tags**: Detailed H3 tag organization
|
||||
- **Heading Balance**: Balanced heading distribution
|
||||
- **Keyword Integration**: Keywords in relevant headings
|
||||
|
||||
#### Content Organization
|
||||
- **Introduction**: Engaging, keyword-rich introduction
|
||||
- **Body Sections**: Well-organized body content
|
||||
- **Conclusion**: Strong, actionable conclusion
|
||||
- **Call-to-Action**: Clear, compelling CTAs
|
||||
- **Content Flow**: Smooth content flow and transitions
|
||||
|
||||
### Technical SEO
|
||||
|
||||
#### Meta Tags
|
||||
- **Title Tag**: Optimized title tag (50-60 characters)
|
||||
- **Meta Description**: Compelling meta description (150-160 characters)
|
||||
- **Meta Keywords**: Relevant meta keywords
|
||||
- **Open Graph**: Social media optimization tags
|
||||
- **Schema Markup**: Structured data implementation
|
||||
|
||||
#### Content Elements
|
||||
- **Image Alt Text**: Descriptive alt text for images
|
||||
- **Internal Links**: Strategic internal linking
|
||||
- **External Links**: Relevant external link placement
|
||||
- **URL Structure**: Clean, keyword-rich URLs
|
||||
- **Content Length**: Optimal content length for SEO
|
||||
#### Wix Integration
|
||||
Direct Wix blog API format for seamless publishing
|
||||
|
||||
## Analysis Results
|
||||
|
||||
### SEO Score
|
||||
### Phase 4 Output Structure
|
||||
|
||||
#### Overall Score
|
||||
- **SEO Score**: Overall SEO performance score (0-100)
|
||||
- **Keyword Score**: Keyword optimization score
|
||||
- **Content Score**: Content quality and structure score
|
||||
- **Technical Score**: Technical SEO implementation score
|
||||
- **Readability Score**: Content readability score
|
||||
Phase 4 returns comprehensive analysis results:
|
||||
|
||||
#### Score Breakdown
|
||||
```json
|
||||
{
|
||||
"overall_score": 85,
|
||||
"keyword_score": 90,
|
||||
"content_score": 80,
|
||||
"technical_score": 85,
|
||||
"readability_score": 88,
|
||||
"recommendations": [
|
||||
"Improve meta description length",
|
||||
"Add more internal links",
|
||||
"Optimize image alt text"
|
||||
]
|
||||
"overall_score": 82,
|
||||
"grade": "Good",
|
||||
"category_scores": {
|
||||
"structure": 85,
|
||||
"keywords": 88,
|
||||
"readability": 78,
|
||||
"quality": 80,
|
||||
"headings": 84
|
||||
},
|
||||
"actionable_recommendations": [
|
||||
{
|
||||
"category": "Structure",
|
||||
"priority": "High",
|
||||
"recommendation": "Add H2 subheadings to improve scannability",
|
||||
"impact": "Better keyword distribution and user experience"
|
||||
},
|
||||
{
|
||||
"category": "Readability",
|
||||
"priority": "Medium",
|
||||
"recommendation": "Simplify complex sentences (average 20 words)",
|
||||
"impact": "Improved readability score and engagement"
|
||||
}
|
||||
],
|
||||
"keyword_analysis": {
|
||||
"primary_keyword_density": 1.2,
|
||||
"semantic_keyword_count": 15,
|
||||
"long_tail_usage": 8,
|
||||
"optimization_status": "Good"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Detailed Recommendations
|
||||
## Use Cases for Different Audiences
|
||||
|
||||
#### Keyword Optimization
|
||||
- **Keyword Density**: Adjust keyword density for optimal results
|
||||
- **Keyword Placement**: Improve keyword placement and distribution
|
||||
- **Keyword Variations**: Add more keyword variations
|
||||
- **Long-Tail Keywords**: Include more long-tail keywords
|
||||
- **Semantic Keywords**: Add semantically related terms
|
||||
### For Technical Content Writers
|
||||
**Scenario**: Creating a technical deep-dive on "React Server Components"
|
||||
|
||||
#### Content Improvement
|
||||
- **Heading Structure**: Improve heading hierarchy
|
||||
- **Paragraph Length**: Optimize paragraph length
|
||||
- **Content Flow**: Enhance content flow and organization
|
||||
- **Readability**: Improve content readability
|
||||
- **Engagement**: Increase content engagement
|
||||
**Phase 4 Delivers**:
|
||||
- Structure score analysis: Identifies need for more code examples in H3 sections
|
||||
- Readability assessment: Detects overly complex technical jargon
|
||||
- Keyword optimization: Suggests semantic keywords like "React SSR" and "Next.js 13"
|
||||
- Actionable fix: "Add 'why it matters' explanations for React Server Component concepts"
|
||||
|
||||
#### Technical Optimization
|
||||
- **Meta Tags**: Optimize meta tags and descriptions
|
||||
- **Image Optimization**: Improve image alt text and optimization
|
||||
- **Internal Linking**: Add strategic internal links
|
||||
- **URL Structure**: Optimize URL structure
|
||||
- **Schema Markup**: Implement structured data
|
||||
**Phase 5 Delivers**:
|
||||
- SEO title: "React Server Components Explained: Complete 2025 Guide"
|
||||
- Meta description: Includes CTA like "Master RSC implementation with practical examples"
|
||||
- JSON-LD: Code schema markup for search engine code indexing
|
||||
- Social tags: #React #WebDevelopment #Programming
|
||||
|
||||
## Competitive Analysis
|
||||
**Value**: Technical content optimized for both search engines and developer audiences
|
||||
|
||||
### Competitor Comparison
|
||||
### For Solopreneurs
|
||||
**Scenario**: Blog on "Starting an Online Course Business"
|
||||
|
||||
#### Content Analysis
|
||||
- **Content Length**: Compare content length with competitors
|
||||
- **Keyword Usage**: Analyze competitor keyword strategies
|
||||
- **Content Structure**: Compare content organization
|
||||
- **Readability**: Assess competitor content readability
|
||||
- **Engagement**: Compare engagement potential
|
||||
**Phase 4 Delivers**:
|
||||
- Quality score: Identifies missing CTA elements in conclusion
|
||||
- Readability: Highlights need to simplify business jargon
|
||||
- Keyword gaps: Discovers missing long-tail "online course pricing strategy"
|
||||
- High-priority fix: "Add specific revenue examples to build credibility"
|
||||
|
||||
#### SEO Performance
|
||||
- **Search Rankings**: Compare search engine rankings
|
||||
- **Traffic Analysis**: Analyze competitor traffic patterns
|
||||
- **Backlink Profile**: Compare backlink strategies
|
||||
- **Social Signals**: Analyze social media performance
|
||||
- **Content Gaps**: Identify content opportunities
|
||||
**Phase 5 Delivers**:
|
||||
- SEO title: "Start Online Course Business: Ultimate 2025 Guide" (56 chars)
|
||||
- Social hashtags: #OnlineCourses #PassiveIncome #Entrepreneurship
|
||||
- Schema.org: EducationalCourse schema for course-related rich snippets
|
||||
- Reading time: "15 minutes" for appropriate audience expectation
|
||||
|
||||
### Gap Analysis
|
||||
**Value**: Professional SEO without hiring expensive consultants
|
||||
|
||||
#### Content Opportunities
|
||||
- **Missing Topics**: Identify topics competitors haven't covered
|
||||
- **Content Depth**: Find areas for deeper content coverage
|
||||
- **Keyword Gaps**: Discover keyword opportunities
|
||||
- **Format Gaps**: Identify content format opportunities
|
||||
- **Audience Gaps**: Find underserved audience segments
|
||||
### For Digital Marketing & SEO Professionals
|
||||
**Scenario**: Strategy content on "Local SEO for Small Businesses"
|
||||
|
||||
#### Competitive Advantages
|
||||
- **Unique Angles**: Develop unique content angles
|
||||
- **Expertise Showcase**: Highlight unique expertise
|
||||
- **Better Coverage**: Provide more comprehensive coverage
|
||||
- **Improved Quality**: Create higher quality content
|
||||
- **Enhanced User Experience**: Improve user experience
|
||||
**Phase 4 Delivers**:
|
||||
- Comprehensive scoring across all 5 categories with detailed breakdown
|
||||
- Competitor analysis integration from Phase 1 research
|
||||
- High-priority recommendations: "Missing Google Business Profile optimization section"
|
||||
- Metrics: Keyword density at 0.9%, target 1.5-2% for competitive keywords
|
||||
|
||||
## Performance Tracking
|
||||
**Phase 5 Delivers**:
|
||||
- Complete metadata package with local SEO schema markup
|
||||
- Location-based Open Graph tags for local business visibility
|
||||
- Multi-format export for WordPress with Yoast compatibility
|
||||
- Structured data including LocalBusiness schema for local SERP features
|
||||
|
||||
### SEO Metrics
|
||||
|
||||
#### Search Performance
|
||||
- **Search Rankings**: Track keyword rankings
|
||||
- **Organic Traffic**: Monitor organic search traffic
|
||||
- **Click-Through Rate**: Track search result clicks
|
||||
- **Impression Share**: Monitor search impression share
|
||||
- **Average Position**: Track average search position
|
||||
|
||||
#### Content Performance
|
||||
- **Page Views**: Monitor page view metrics
|
||||
- **Time on Page**: Track user engagement time
|
||||
- **Bounce Rate**: Monitor bounce rate
|
||||
- **Conversion Rate**: Track conversion metrics
|
||||
- **Social Shares**: Monitor social media shares
|
||||
|
||||
### Analytics Integration
|
||||
|
||||
#### Google Analytics
|
||||
- **Traffic Sources**: Analyze traffic sources
|
||||
- **User Behavior**: Track user behavior patterns
|
||||
- **Content Performance**: Monitor content performance
|
||||
- **Conversion Tracking**: Track conversion metrics
|
||||
- **Audience Insights**: Analyze audience demographics
|
||||
|
||||
#### Search Console
|
||||
- **Search Queries**: Monitor search query performance
|
||||
- **Click Data**: Track click-through rates
|
||||
- **Impression Data**: Monitor search impressions
|
||||
- **Position Data**: Track search position changes
|
||||
- **Coverage Issues**: Identify technical issues
|
||||
**Value**: Enterprise-grade SEO optimization with detailed analytics
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Content Optimization
|
||||
### Phase 4: SEO Analysis Best Practices
|
||||
|
||||
#### Keyword Strategy
|
||||
1. **Primary Focus**: Focus on one primary keyword per page
|
||||
2. **Natural Integration**: Integrate keywords naturally
|
||||
3. **Semantic Keywords**: Use semantically related terms
|
||||
4. **Long-Tail Keywords**: Target specific, long-tail phrases
|
||||
5. **User Intent**: Match keywords to user search intent
|
||||
#### Pre-Analysis Preparation
|
||||
1. **Complete Content**: Ensure all sections are finalized before analysis
|
||||
2. **Research Integration**: Verify Phase 1 research data includes keywords
|
||||
3. **Word Count**: Target 1000-3000 words for optimal SEO analysis
|
||||
4. **Structure Review**: Confirm proper heading hierarchy (H1, H2, H3)
|
||||
5. **Content Quality**: Ensure content is factually accurate and complete
|
||||
|
||||
#### Content Quality
|
||||
1. **Original Content**: Create original, unique content
|
||||
2. **Comprehensive Coverage**: Provide comprehensive topic coverage
|
||||
3. **Expert Authority**: Demonstrate expertise and authority
|
||||
4. **User Value**: Provide clear value to users
|
||||
5. **Engagement**: Create engaging, shareable content
|
||||
#### Using "Apply Recommendations"
|
||||
1. **Review First**: Always review recommendations before applying
|
||||
2. **Selective Application**: Consider applying high-priority fixes first
|
||||
3. **Edit After**: Manually refine AI-applied changes for your voice
|
||||
4. **Preserve Intent**: Verify AI preserved your original meaning
|
||||
5. **Re-Analyze**: Run Phase 4 again after applying to track improvement
|
||||
|
||||
### Technical SEO
|
||||
### Phase 5: Metadata Generation Best Practices
|
||||
|
||||
#### On-Page Optimization
|
||||
1. **Title Tags**: Create compelling, keyword-rich titles
|
||||
2. **Meta Descriptions**: Write engaging meta descriptions
|
||||
3. **Heading Structure**: Use proper heading hierarchy
|
||||
4. **Internal Linking**: Implement strategic internal linking
|
||||
5. **Image Optimization**: Optimize images with alt text
|
||||
#### Metadata Optimization
|
||||
1. **Title Length**: Keep SEO titles to 50-60 characters for SERP display
|
||||
2. **Meta Descriptions**: Write 150-160 character descriptions with CTA in first 120 chars
|
||||
3. **Keyword Placement**: Front-load primary keyword in title and first 120 chars of description
|
||||
4. **Uniqueness**: Ensure metadata is unique for each blog post
|
||||
5. **Brand Consistency**: Include brand name where appropriate without exceeding length limits
|
||||
|
||||
#### Site Performance
|
||||
1. **Page Speed**: Optimize page loading speed
|
||||
2. **Mobile Optimization**: Ensure mobile-friendly design
|
||||
3. **SSL Certificate**: Use HTTPS for security
|
||||
4. **Clean URLs**: Use clean, descriptive URLs
|
||||
5. **Schema Markup**: Implement structured data
|
||||
#### Social Media Optimization
|
||||
1. **Image Planning**: Prepare 1200x630px images for Open Graph sharing
|
||||
2. **Twitter Cards**: Ensure Twitter Card images are 1200x600px minimum
|
||||
3. **Hashtag Strategy**: Mix industry-specific, trending, and branded hashtags
|
||||
4. **Platform-Specific**: Review Open Graph vs Twitter Card differences
|
||||
5. **Testing**: Use Facebook Debugger and Twitter Card Validator before publishing
|
||||
|
||||
## Advanced Features
|
||||
### SEO Workflow Integration
|
||||
|
||||
### AI-Powered Optimization
|
||||
#### Phase 4 to Phase 5 Flow
|
||||
1. **Score First**: Always complete Phase 4 analysis before metadata generation
|
||||
2. **Apply Fixes**: Use "Apply Recommendations" to improve scores to 80+
|
||||
3. **Generate Metadata**: Run Phase 5 with optimized content
|
||||
4. **Review Metadata**: Verify metadata reflects SEO improvements
|
||||
5. **Export & Publish**: Copy metadata formats for your platform
|
||||
|
||||
#### Content Enhancement
|
||||
- **Automatic Optimization**: AI-powered content optimization
|
||||
- **Keyword Suggestions**: Intelligent keyword recommendations
|
||||
- **Content Improvement**: Automated content improvement suggestions
|
||||
- **Readability Enhancement**: AI-powered readability improvements
|
||||
- **Engagement Optimization**: Optimize for user engagement
|
||||
|
||||
#### Performance Prediction
|
||||
- **Ranking Prediction**: Predict potential search rankings
|
||||
- **Traffic Forecasting**: Forecast organic traffic potential
|
||||
- **Engagement Prediction**: Predict user engagement levels
|
||||
- **Conversion Optimization**: Optimize for conversions
|
||||
- **ROI Analysis**: Analyze return on SEO investment
|
||||
|
||||
### Customization Options
|
||||
|
||||
#### Analysis Settings
|
||||
- **Keyword Preferences**: Set keyword analysis preferences
|
||||
- **Competitor Selection**: Choose competitors for analysis
|
||||
- **Analysis Depth**: Adjust analysis depth and detail
|
||||
- **Optimization Goals**: Set specific optimization goals
|
||||
- **Quality Standards**: Define quality standards and thresholds
|
||||
|
||||
#### Reporting Options
|
||||
- **Custom Reports**: Create custom SEO reports
|
||||
- **Scheduled Reports**: Set up automated reporting
|
||||
- **Performance Dashboards**: Create performance dashboards
|
||||
- **Alert Systems**: Set up performance alerts
|
||||
- **Export Options**: Export data in various formats
|
||||
#### Performance Optimization
|
||||
1. **Cache Utilization**: Leverage research caching from Phase 1 for related topics
|
||||
2. **Batch Analysis**: Analyze multiple blog drafts in one session to improve learning
|
||||
3. **Score Tracking**: Monitor SEO score trends across multiple posts
|
||||
4. **A/B Testing**: Test different metadata variations for CTR optimization
|
||||
5. **Analytics Integration**: Connect to Google Analytics/Search Console post-publish
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
### Common Issues & Solutions
|
||||
|
||||
#### SEO Analysis Problems
|
||||
- **Low SEO Scores**: Address low SEO performance
|
||||
- **Keyword Issues**: Resolve keyword optimization problems
|
||||
- **Content Quality**: Improve content quality and structure
|
||||
- **Technical Issues**: Fix technical SEO problems
|
||||
- **Performance Issues**: Address performance concerns
|
||||
#### Low SEO Scores (< 70)
|
||||
**Problem**: Overall SEO score below 70 or grade showing "Needs Improvement"
|
||||
|
||||
#### Optimization Challenges
|
||||
- **Keyword Overuse**: Avoid keyword stuffing
|
||||
- **Content Duplication**: Prevent duplicate content issues
|
||||
- **Technical Errors**: Fix technical SEO errors
|
||||
- **Performance Problems**: Resolve performance issues
|
||||
- **Competition Analysis**: Improve competitive analysis
|
||||
**Solutions**:
|
||||
- **Check Category Scores**: Review individual category breakdowns to identify weak areas
|
||||
- **Apply High-Priority Recommendations**: Focus on critical fixes first
|
||||
- **Verify Content Length**: Ensure 1000+ words for comprehensive analysis
|
||||
- **Review Heading Structure**: Confirm proper H1/H2/H3 hierarchy
|
||||
- **Re-run Analysis**: After fixing issues, re-analyze to track improvements
|
||||
|
||||
#### Keyword Analysis Issues
|
||||
**Problem**: Low keyword scores or missing keyword recommendations
|
||||
|
||||
**Solutions**:
|
||||
- **Verify Phase 1 Research**: Ensure Phase 1 keyword analysis completed successfully
|
||||
- **Check Keyword Density**: Primary keyword should be 1-2% of total content
|
||||
- **Review Placement**: Ensure keywords appear in title, first paragraph, and subheadings
|
||||
- **Add Semantic Keywords**: Integrate related terms naturally throughout content
|
||||
- **Consider Long-Tail**: Include 3-5 long-tail keyword variations
|
||||
|
||||
#### "Apply Recommendations" Not Working
|
||||
**Problem**: Content doesn't update or changes seem minimal
|
||||
|
||||
**Solutions**:
|
||||
- **Check Recommendations**: Verify actionable recommendations are actually present
|
||||
- **Review Normalization**: Check if AI properly matched section IDs
|
||||
- **Refresh UI**: Try closing and reopening the SEO Analysis modal
|
||||
- **Manual Review**: Compare original vs. updated sections for subtle changes
|
||||
- **Re-Analyze**: Run Phase 4 again to see if scores improved
|
||||
|
||||
#### Metadata Generation Issues
|
||||
**Problem**: Phase 5 generates incomplete or low-quality metadata
|
||||
|
||||
**Solutions**:
|
||||
- **Content Completeness**: Ensure blog content is finalized before metadata generation
|
||||
- **Title/Slug Issues**: Generate metadata after choosing final blog title
|
||||
- **Length Constraints**: Verify SEO titles (50-60) and descriptions (150-160) are respected
|
||||
- **Re-run Phase 5**: If results are suboptimal, regenerate with clearer content
|
||||
- **Manual Refinement**: Edit generated metadata for brand voice consistency
|
||||
|
||||
### Getting Help
|
||||
|
||||
#### Support Resources
|
||||
- **Documentation**: Review SEO analysis documentation
|
||||
- **Tutorials**: Watch SEO optimization tutorials
|
||||
- **Best Practices**: Follow SEO best practices
|
||||
- **Community**: Join user community discussions
|
||||
- **Support**: Contact technical support
|
||||
- **[Workflow Guide](workflow-guide.md)**: Complete 6-phase walkthrough
|
||||
- **[Blog Writer Overview](overview.md)**: Overview of all phases
|
||||
- **[API Reference](api-reference.md)**: Technical API documentation
|
||||
- **[Best Practices](../../guides/best-practices.md)**: Advanced optimization tips
|
||||
|
||||
#### Optimization Tips
|
||||
- **Regular Analysis**: Perform regular SEO analysis
|
||||
- **Continuous Improvement**: Continuously improve SEO performance
|
||||
- **Performance Monitoring**: Monitor SEO performance metrics
|
||||
- **Competitive Analysis**: Regular competitive analysis
|
||||
- **Quality Assurance**: Maintain high quality standards
|
||||
#### Performance Tips
|
||||
- **Batch Processing**: Analyze multiple drafts in one session for efficiency
|
||||
- **Cache Benefits**: Reuse research from Phase 1 to speed up workflow
|
||||
- **Score Tracking**: Monitor SEO improvements across multiple blog posts
|
||||
- **Metadata Testing**: Use Facebook Debugger and Twitter Card Validator
|
||||
- **Analytics Setup**: Connect Google Analytics/Search Console for post-publish tracking
|
||||
|
||||
---
|
||||
|
||||
*Ready to optimize your content for search engines? [Start with our First Steps Guide](../../getting-started/first-steps.md) and [Explore Blog Writer Features](overview.md) to begin creating SEO-optimized, high-ranking content!*
|
||||
## Next Steps
|
||||
|
||||
Now that you understand Phase 4 & 5, explore the complete workflow:
|
||||
|
||||
- **[Phase 1: Research](research.md)** - Comprehensive research capabilities
|
||||
- **[Complete Workflow Guide](workflow-guide.md)** - End-to-end 6-phase walkthrough
|
||||
- **[Blog Writer Overview](overview.md)** - All phases overview
|
||||
- **[Getting Started Guide](../../getting-started/quick-start.md)** - Quick start for new users
|
||||
|
||||
---
|
||||
|
||||
*Ready to optimize your content for search engines? Check out the [Workflow Guide](workflow-guide.md) to see how Phase 4 & 5 integrate into the complete blog creation process!*
|
||||
|
||||
@@ -8,36 +8,36 @@ The ALwrity Blog Writer follows a sophisticated 6-phase workflow designed to cre
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Start: Keywords & Topic] --> B[Phase 1: Research & Discovery]
|
||||
B --> C[Phase 2: Outline Generation]
|
||||
A[Start: Keywords & Topic] --> B[Phase 1: Research & Strategy]
|
||||
B --> C[Phase 2: Intelligent Outline]
|
||||
C --> D[Phase 3: Content Generation]
|
||||
D --> E[Phase 4: SEO Analysis]
|
||||
E --> F[Phase 5: Quality Assurance]
|
||||
F --> G[Phase 6: Publishing]
|
||||
E --> F[Phase 5: SEO Metadata]
|
||||
F --> G[Phase 6: Publish & Distribute]
|
||||
|
||||
B --> B1[Web Search & Source Collection]
|
||||
B --> B1[Google Search Grounding]
|
||||
B --> B2[Competitor Analysis]
|
||||
B --> B3[Research Caching]
|
||||
|
||||
C --> C1[Content Structure Planning]
|
||||
C --> C2[Section Definition]
|
||||
C --> C3[Source Mapping]
|
||||
C --> C1[AI Outline Generation]
|
||||
C --> C2[Source Mapping]
|
||||
C --> C3[Title Generation]
|
||||
|
||||
D --> D1[Section-by-Section Writing]
|
||||
D --> D2[Citation Integration]
|
||||
D --> D3[Continuity Tracking]
|
||||
D --> D2[Context Memory]
|
||||
D --> D3[Flow Analysis]
|
||||
|
||||
E --> E1[SEO Scoring]
|
||||
E --> E2[Keyword Analysis]
|
||||
E --> E3[Readability Assessment]
|
||||
E --> E2[Actionable Recommendations]
|
||||
E --> E3[AI-Powered Refinement]
|
||||
|
||||
F --> F1[Fact Verification]
|
||||
F --> F2[Hallucination Detection]
|
||||
F --> F3[Quality Scoring]
|
||||
F --> F1[Comprehensive Metadata]
|
||||
F --> F2[Open Graph & Twitter Cards]
|
||||
F --> F3[Schema.org Markup]
|
||||
|
||||
G --> G1[Platform Integration]
|
||||
G --> G2[Metadata Generation]
|
||||
G --> G3[Content Publishing]
|
||||
G --> G1[Multi-Platform Publishing]
|
||||
G --> G2[Scheduling]
|
||||
G --> G3[Version Management]
|
||||
|
||||
style A fill:#e3f2fd
|
||||
style B fill:#e8f5e8
|
||||
@@ -58,40 +58,40 @@ gantt
|
||||
dateFormat X
|
||||
axisFormat %M:%S
|
||||
|
||||
section Research
|
||||
section Phase 1 Research
|
||||
Keyword Analysis :0, 10
|
||||
Web Search :10, 30
|
||||
Source Collection :20, 40
|
||||
Competitor Analysis :30, 50
|
||||
Research Caching :40, 60
|
||||
Google Search :10, 40
|
||||
Source Extraction :30, 50
|
||||
Competitor Analysis :40, 60
|
||||
Research Caching :50, 60
|
||||
|
||||
section Outline
|
||||
Structure Planning :60, 70
|
||||
Section Definition :70, 80
|
||||
Source Mapping :80, 90
|
||||
Title Generation :90, 100
|
||||
section Phase 2 Outline
|
||||
AI Structure Planning :60, 80
|
||||
Section Definition :75, 90
|
||||
Source Mapping :85, 100
|
||||
Title Generation :95, 110
|
||||
|
||||
section Content
|
||||
Section 1 Writing :100, 120
|
||||
Section 2 Writing :120, 140
|
||||
Section 3 Writing :140, 160
|
||||
Citation Integration :160, 170
|
||||
section Phase 3 Content
|
||||
Section 1 Writing :110, 140
|
||||
Section 2 Writing :130, 160
|
||||
Section 3 Writing :150, 180
|
||||
Context Continuity :170, 200
|
||||
|
||||
section SEO
|
||||
Structure Analysis :170, 180
|
||||
Keyword Analysis :180, 190
|
||||
Readability Check :190, 200
|
||||
SEO Scoring :200, 210
|
||||
section Phase 4 SEO
|
||||
Parallel Analysis :200, 215
|
||||
AI Scoring :210, 230
|
||||
Recommendations :220, 235
|
||||
Apply Refinement :230, 250
|
||||
|
||||
section Quality
|
||||
Fact Verification :210, 220
|
||||
Hallucination Check :220, 230
|
||||
Quality Scoring :230, 240
|
||||
section Phase 5 Metadata
|
||||
Core Metadata :250, 265
|
||||
Social Tags :260, 275
|
||||
Schema Markup :270, 285
|
||||
|
||||
section Publishing
|
||||
Platform Integration :240, 250
|
||||
Metadata Generation :250, 260
|
||||
Content Publishing :260, 270
|
||||
section Phase 6 Publish
|
||||
Platform Setup :285, 295
|
||||
Content Publishing :290, 310
|
||||
Verification :305, 320
|
||||
```
|
||||
|
||||
## 📋 Prerequisites
|
||||
@@ -104,7 +104,7 @@ Before starting, ensure you have:
|
||||
- **Content Goals**: Defined objectives for your blog post
|
||||
- **Word Count Target**: Desired length (typically 1000-3000 words)
|
||||
|
||||
## 🔍 Phase 1: Research & Discovery
|
||||
## 🔍 Phase 1: Research & Strategy
|
||||
|
||||
### Step 1: Initiate Research
|
||||
|
||||
@@ -170,7 +170,7 @@ Before starting, ensure you have:
|
||||
- ✅ Relevant to your target audience
|
||||
- ✅ Covers multiple aspects of your topic
|
||||
|
||||
## 📝 Phase 2: Outline Generation
|
||||
## 📝 Phase 2: Intelligent Outline
|
||||
|
||||
### Step 1: Generate Outline
|
||||
|
||||
@@ -235,6 +235,31 @@ Before starting, ensure you have:
|
||||
- **Add Sections**: Include missing content areas
|
||||
- **Improve SEO**: Better keyword distribution
|
||||
|
||||
### 🖼️ Generate Images for Sections (Optional)
|
||||
|
||||
While in Phase 2, you can generate images for your outline sections.
|
||||
|
||||
**How It Works:**
|
||||
1. Click the **"🖼️ Generate Image"** button on any section in the outline
|
||||
2. Image modal opens with auto-generated prompt based on section heading
|
||||
3. Click **"Suggest Prompt"** for AI-optimized suggestions
|
||||
4. Optionally open **"Advanced Image Options"** for custom settings
|
||||
5. Choose provider: Stability AI, Hugging Face, or Gemini
|
||||
6. Generate and images auto-insert into outline and metadata
|
||||
|
||||
**Best Practices:**
|
||||
- Generate images during outline review
|
||||
- Use specific, descriptive prompts
|
||||
- Match image style to your brand
|
||||
- Generate multiple variations if needed
|
||||
|
||||
**Image Features:**
|
||||
- Provider selection (Stability AI, Hugging Face, Gemini)
|
||||
- Aspect ratio options (1:1, 16:9, 4:3)
|
||||
- Style customization
|
||||
- Auto-prompt suggestions
|
||||
- Platform-optimized outputs
|
||||
|
||||
## ✍️ Phase 3: Content Generation
|
||||
|
||||
### Step 1: Generate Section Content
|
||||
@@ -311,7 +336,71 @@ Repeat the process for each outline section:
|
||||
- Use continuity metrics to ensure flow
|
||||
- Adjust tone and style as needed
|
||||
|
||||
## 🔍 Phase 4: SEO Analysis & Optimization
|
||||
### Advanced Features in Phase 3
|
||||
|
||||
#### ✨ Assistive Writing (Continue Writing)
|
||||
As you write in any blog section, the AI provides contextual suggestions to help you continue.
|
||||
|
||||
**How It Works:**
|
||||
1. Type 20+ words in any section
|
||||
2. First suggestion appears automatically below your cursor
|
||||
3. Click **"Accept"** to insert or **"Dismiss"** to skip
|
||||
4. Click **"✍️ Continue Writing"** to request more suggestions
|
||||
5. Suggestions include source citations when available
|
||||
|
||||
**Benefits:**
|
||||
- Real-time writing assistance
|
||||
- Context-aware continuations
|
||||
- Source-backed suggestions
|
||||
- Cost-optimized (first auto, then manual)
|
||||
|
||||
#### Quick Edit Options
|
||||
Select text to access quick edit options in the context menu:
|
||||
|
||||
**Available Quick Edits:**
|
||||
- **✏️ Improve**: Enhance readability and engagement
|
||||
- **➕ Add Transition**: Insert transitional phrases (Furthermore, Additionally, Moreover)
|
||||
- **📏 Shorten**: Condense while maintaining meaning
|
||||
- **📝 Expand**: Add explanatory content and insights
|
||||
- **💼 Professionalize**: Make more formal (convert contractions, improve tone)
|
||||
- **📊 Add Data**: Insert statistical backing statements
|
||||
|
||||
**How It Works:**
|
||||
1. Select any text in your blog content
|
||||
2. Context menu appears near your cursor
|
||||
3. Choose a quick edit option
|
||||
4. Text updates instantly
|
||||
|
||||
**Best For:**
|
||||
- Improving flow between sentences
|
||||
- Adjusting tone and formality
|
||||
- Adding supporting statements
|
||||
- Professionalizing casual language
|
||||
|
||||
#### 🔍 Fact-Checking
|
||||
Verify claims and facts in your content with AI-powered checking.
|
||||
|
||||
**How It Works:**
|
||||
1. Select any paragraph or claim text
|
||||
2. Right-click or use the context menu
|
||||
3. Click **"🔍 Fact Check"**
|
||||
4. Wait 15-30 seconds for analysis
|
||||
5. Review detailed results with supporting/refuting sources
|
||||
6. Click **"Apply Fix"** to insert source links if needed
|
||||
|
||||
**What Gets Analyzed:**
|
||||
- Verifiable claims and statements
|
||||
- Statistical data and percentages
|
||||
- Dates, names, and events
|
||||
- Industry-specific facts
|
||||
|
||||
**Results Include:**
|
||||
- Claim-by-claim confidence scores
|
||||
- Supporting evidence URLs
|
||||
- Refuting sources (if applicable)
|
||||
- Overall factual accuracy score
|
||||
|
||||
## 🔍 Phase 4: SEO Analysis
|
||||
|
||||
### Step 1: Perform SEO Analysis
|
||||
|
||||
@@ -356,7 +445,21 @@ Repeat the process for each outline section:
|
||||
- ✅ Proper heading structure
|
||||
- ✅ Actionable recommendations
|
||||
|
||||
### Step 3: Generate SEO Metadata
|
||||
### Step 3: Apply SEO Recommendations (Optional)
|
||||
|
||||
**Endpoint**: `POST /api/blog/seo/apply-recommendations`
|
||||
|
||||
Use the "Apply Recommendations" button to automatically improve your content based on SEO analysis. The AI will:
|
||||
- Optimize keyword density and placement
|
||||
- Improve content structure and headings
|
||||
- Enhance readability and flow
|
||||
- Maintain your original voice and intent
|
||||
|
||||
**Expected Duration**: 20-40 seconds
|
||||
|
||||
## 📝 Phase 5: SEO Metadata
|
||||
|
||||
### Step 1: Generate Core Metadata
|
||||
|
||||
**Endpoint**: `POST /api/blog/seo/metadata`
|
||||
|
||||
@@ -373,66 +476,50 @@ Repeat the process for each outline section:
|
||||
}
|
||||
```
|
||||
|
||||
**Generated Metadata**:
|
||||
- **SEO Title**: Optimized for search engines
|
||||
- **Meta Description**: Compelling 155-character description
|
||||
- **URL Slug**: SEO-friendly URL structure
|
||||
- **Tags & Categories**: Relevant content classification
|
||||
- **Social Media Tags**: Open Graph and Twitter Card data
|
||||
- **JSON-LD Schema**: Structured data for search engines
|
||||
**What Happens** (First AI Call):
|
||||
1. **SEO Title**: Optimized for search engines (50-60 chars)
|
||||
2. **Meta Description**: Compelling description with CTA (150-160 chars)
|
||||
3. **URL Slug**: Clean, hyphenated, keyword-rich (3-5 words)
|
||||
4. **Blog Tags**: Mix of primary, semantic, and long-tail keywords (5-8)
|
||||
5. **Blog Categories**: Industry-standard classification (2-3)
|
||||
6. **Social Hashtags**: Industry-specific with trending terms (5-10)
|
||||
7. **Reading Time**: Calculated from word count
|
||||
|
||||
## 🛡️ Phase 5: Quality Assurance
|
||||
**Expected Duration**: 10-15 seconds
|
||||
|
||||
### Step 1: Perform Hallucination Check
|
||||
### Step 2: Generate Social Media & Schema Metadata
|
||||
|
||||
**Endpoint**: `POST /api/blog/quality/hallucination-check`
|
||||
**What Happens** (Second AI Call):
|
||||
1. **Open Graph Tags**: Optimized for Facebook/LinkedIn sharing
|
||||
2. **Twitter Cards**: Twitter-specific optimization
|
||||
3. **JSON-LD Schema**: Structured data for search engines
|
||||
4. **Multi-Format Export**: WordPress, Wix, HTML, JSON-LD ready formats
|
||||
|
||||
**Request Example**:
|
||||
```json
|
||||
{
|
||||
"content": "Complete blog content here...",
|
||||
"sources": [
|
||||
"https://example.com/source1",
|
||||
"https://example.com/source2"
|
||||
]
|
||||
}
|
||||
```
|
||||
**Generated Metadata Output**:
|
||||
- **Core Elements**: Title, description, URL slug, tags, categories
|
||||
- **Social Optimization**: Open Graph and Twitter Card tags
|
||||
- **Structured Data**: Article schema with author, dates, organization
|
||||
- **Platform Formats**: Copy-ready for WordPress, Wix, custom
|
||||
|
||||
**What Happens**:
|
||||
1. **Fact Verification**: Checks content against research sources
|
||||
2. **Hallucination Detection**: Identifies potential AI-generated inaccuracies
|
||||
3. **Content Validation**: Ensures factual accuracy and credibility
|
||||
4. **Quality Scoring**: Generates content quality metrics
|
||||
**Expected Duration**: 10-15 seconds
|
||||
|
||||
**Expected Duration**: 15-25 seconds
|
||||
|
||||
### Step 2: Review Quality Results
|
||||
|
||||
**Key Metrics**:
|
||||
- **Factual Accuracy**: Percentage of verified claims
|
||||
- **Source Coverage**: Percentage of content backed by sources
|
||||
- **Quality Score**: Overall content quality (0-100)
|
||||
- **Improvement Suggestions**: Specific enhancement recommendations
|
||||
### Step 3: Review & Export Metadata
|
||||
|
||||
**Quality Checklist**:
|
||||
- ✅ High factual accuracy (90%+)
|
||||
- ✅ Good source coverage (80%+)
|
||||
- ✅ Quality score above 85
|
||||
- ✅ No major factual errors
|
||||
- ✅ Clear improvement suggestions
|
||||
- ✅ SEO title is 50-60 characters with primary keyword
|
||||
- ✅ Meta description includes CTA in first 120 chars
|
||||
- ✅ URL slug is clean, readable, and keyword-rich
|
||||
- ✅ Tags and categories are relevant and varied
|
||||
- ✅ Social tags are optimized for each platform
|
||||
- ✅ Schema markup is valid JSON-LD
|
||||
|
||||
### Step 3: Content Optimization (Optional)
|
||||
**Export Options**:
|
||||
- Copy HTML meta tags directly to your platform
|
||||
- Export JSON-LD for search engines
|
||||
- WordPress-ready format with Yoast compatibility
|
||||
- Wix integration format
|
||||
|
||||
**Endpoint**: `POST /api/blog/section/optimize`
|
||||
|
||||
**Common Optimizations**:
|
||||
- **Improve Readability**: Simplify complex sentences
|
||||
- **Enhance Engagement**: Add compelling examples and stories
|
||||
- **Strengthen Arguments**: Provide more supporting evidence
|
||||
- **Fix Flow Issues**: Improve section transitions
|
||||
- **Optimize Keywords**: Better keyword integration
|
||||
|
||||
## 🚀 Phase 6: Publishing & Distribution
|
||||
## 🚀 Phase 6: Publish & Distribute
|
||||
|
||||
### Step 1: Prepare for Publishing
|
||||
|
||||
|
||||
162
frontend/public/BLOG_WRITER_ASSETS_GUIDE.md
Normal file
162
frontend/public/BLOG_WRITER_ASSETS_GUIDE.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Blog Writer Assets Guide
|
||||
|
||||
## 📁 Folder Structure
|
||||
|
||||
```
|
||||
frontend/public/
|
||||
├── images/
|
||||
│ └── (add 24 feature images here)
|
||||
├── videos/
|
||||
│ └── (add 6 demo videos here)
|
||||
├── blog-writer-bg.png (already exists ✅)
|
||||
└── BLOG_WRITER_ASSETS_GUIDE.md (this file)
|
||||
```
|
||||
|
||||
## 🖼️ Required Images (24 total)
|
||||
|
||||
### Phase 1: Research & Strategy (4 images)
|
||||
- `images/research-google-grounding.jpg` - Screenshot/video frame showing Google Search grounding in action
|
||||
- `images/research-competitor.jpg` - Screenshot of competitor analysis results
|
||||
- `images/research-keywords.jpg` - Screenshot showing keyword analysis and clustering
|
||||
- `images/research-angles.jpg` - Screenshot of AI-generated content angle suggestions
|
||||
|
||||
### Phase 2: Intelligent Outline (4 images)
|
||||
- `images/outline-generation.jpg` - Screenshot of AI outline generation interface
|
||||
- `images/outline-grounding.jpg` - Screenshot showing source mapping and grounding scores
|
||||
- `images/outline-refine.jpg` - Screenshot of interactive outline refinement (add/remove/merge sections)
|
||||
- `images/outline-titles.jpg` - Screenshot of multiple AI-generated title options with SEO scores
|
||||
|
||||
### Phase 3: Content Generation (4 images)
|
||||
- `images/content-generation.jpg` - Screenshot of section-by-section content generation
|
||||
- `images/content-continuity.jpg` - Screenshot showing continuity analysis and flow metrics
|
||||
- `images/content-sources.jpg` - Screenshot of automatic source integration and citations
|
||||
- `images/content-medium.jpg` - Screenshot of Medium blog mode quick generation
|
||||
|
||||
### Phase 4: SEO Analysis (4 images)
|
||||
- `images/seo-scoring.jpg` - Screenshot of comprehensive SEO scoring dashboard
|
||||
- `images/seo-recommendations.jpg` - Screenshot of actionable SEO recommendations list
|
||||
- `images/seo-apply.jpg` - Screenshot of AI-powered content refinement interface
|
||||
- `images/seo-keywords.jpg` - Screenshot of keyword density heatmap and analysis
|
||||
|
||||
### Phase 5: SEO Metadata (4 images)
|
||||
- `images/metadata-comprehensive.jpg` - Screenshot of full metadata generation interface
|
||||
- `images/metadata-social.jpg` - Screenshot of Open Graph and Twitter Cards configuration
|
||||
- `images/metadata-schema.jpg` - Screenshot of structured data (Schema.org) markup
|
||||
- `images/metadata-export.jpg` - Screenshot of multi-format output options (HTML, JSON-LD, WordPress, Wix)
|
||||
|
||||
### Phase 6: Publish & Distribute (4 images)
|
||||
- `images/publish-platforms.jpg` - Screenshot of multi-platform publishing options (WordPress, Wix, Medium)
|
||||
- `images/publish-schedule.jpg` - Screenshot of content scheduling interface with calendar
|
||||
- `images/publish-versions.jpg` - Screenshot of revision management and version history
|
||||
- `images/publish-analytics.jpg` - Screenshot of post-publish analytics dashboard
|
||||
|
||||
## 🎬 Required Videos (6 total)
|
||||
|
||||
### Phase 1: Research & Strategy
|
||||
- `videos/phase1-research.mp4` - Demo video showing:
|
||||
- Keyword input and analysis
|
||||
- Google Search grounding in action
|
||||
- Competitor analysis results
|
||||
- Content angle generation
|
||||
|
||||
### Phase 2: Intelligent Outline
|
||||
- `videos/phase2-outline.mp4` - Demo video showing:
|
||||
- AI outline generation from research
|
||||
- Source mapping and grounding scores
|
||||
- Interactive refinement (add/remove sections)
|
||||
- Title generation with SEO scores
|
||||
|
||||
### Phase 3: Content Generation
|
||||
- `videos/phase3-content.mp4` - Demo video showing:
|
||||
- Section-by-section content generation
|
||||
- Continuity analysis and flow metrics
|
||||
- Source integration and citations
|
||||
- Medium blog mode
|
||||
|
||||
### Phase 4: SEO Analysis
|
||||
- `videos/phase4-seo.mp4` - Demo video showing:
|
||||
- SEO scoring dashboard
|
||||
- Actionable recommendations
|
||||
- AI-powered content refinement ("Apply Recommendations")
|
||||
- Keyword analysis
|
||||
|
||||
### Phase 5: SEO Metadata
|
||||
- `videos/phase5-metadata.mp4` - Demo video showing:
|
||||
- Comprehensive metadata generation
|
||||
- Open Graph and Twitter Cards
|
||||
- Structured data (Schema.org)
|
||||
- Multi-format export options
|
||||
|
||||
### Phase 6: Publish & Distribute
|
||||
- `videos/phase6-publish.mp4` - Demo video showing:
|
||||
- Multi-platform publishing
|
||||
- Content scheduling
|
||||
- Version management
|
||||
- Analytics integration
|
||||
|
||||
## 📝 Image Requirements
|
||||
|
||||
- **Format**: JPG/JPEG (recommended for photos) or PNG (recommended for screenshots)
|
||||
- **Resolution**:
|
||||
- Minimum: 1200x800px (3:2 aspect ratio for cards)
|
||||
- Recommended: 1920x1280px for best quality
|
||||
- **File Size**: Keep under 500KB each for fast loading
|
||||
- **Content**: Actual screenshots from the working application
|
||||
|
||||
## 🎥 Video Requirements
|
||||
|
||||
- **Format**: MP4 (H.264 codec recommended)
|
||||
- **Duration**: 30-90 seconds per phase
|
||||
- **Resolution**:
|
||||
- Minimum: 1280x720 (720p)
|
||||
- Recommended: 1920x1080 (1080p)
|
||||
- **File Size**: Optimize to keep under 10MB each if possible
|
||||
- **Content**: Screen recordings showing the actual features in action
|
||||
|
||||
## 🚀 How to Add Assets
|
||||
|
||||
1. **Create the folders** (already created with .gitkeep files):
|
||||
```bash
|
||||
# Folders are already created, just add files
|
||||
frontend/public/images/
|
||||
frontend/public/videos/
|
||||
```
|
||||
|
||||
2. **Add your images**:
|
||||
- Take screenshots or create mockups
|
||||
- Optimize for web (compress if needed)
|
||||
- Save with exact filenames listed above
|
||||
- Place in `frontend/public/images/` folder
|
||||
|
||||
3. **Add your videos**:
|
||||
- Record screen captures of each phase
|
||||
- Edit to show key features
|
||||
- Optimize file size
|
||||
- Save with exact filenames listed above
|
||||
- Place in `frontend/public/videos/` folder
|
||||
|
||||
4. **Test the integration**:
|
||||
- Run the app: `cd frontend && npm start`
|
||||
- Open Blog Writer
|
||||
- Click "🚀 ALwrity Blog Writer SuperPowers"
|
||||
- Expand each phase to see images and videos
|
||||
|
||||
## ✅ Quick Checklist
|
||||
|
||||
- [ ] Phase 1: Research images (4) + video (1)
|
||||
- [ ] Phase 2: Outline images (4) + video (1)
|
||||
- [ ] Phase 3: Content images (4) + video (1)
|
||||
- [ ] Phase 4: SEO images (4) + video (1)
|
||||
- [ ] Phase 5: Metadata images (4) + video (1)
|
||||
- [ ] Phase 6: Publish images (4) + video (1)
|
||||
- [ ] Total: 24 images + 6 videos = 30 assets
|
||||
|
||||
## 📍 Current Implementation
|
||||
|
||||
The images and videos are referenced in:
|
||||
- `frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx`
|
||||
- Each phase card shows video when expanded
|
||||
- Each feature card shows image placeholder
|
||||
|
||||
Paths are already configured to use `/images/` and `/videos/` from the public folder.
|
||||
|
||||
27
frontend/public/images/.gitkeep
Normal file
27
frontend/public/images/.gitkeep
Normal file
@@ -0,0 +1,27 @@
|
||||
# Blog Writer Phase Images
|
||||
# Add your phase images here:
|
||||
# - research-google-grounding.jpg
|
||||
# - research-competitor.jpg
|
||||
# - research-keywords.jpg
|
||||
# - research-angles.jpg
|
||||
# - outline-generation.jpg
|
||||
# - outline-grounding.jpg
|
||||
# - outline-refine.jpg
|
||||
# - outline-titles.jpg
|
||||
# - content-generation.jpg
|
||||
# - content-continuity.jpg
|
||||
# - content-sources.jpg
|
||||
# - content-medium.jpg
|
||||
# - seo-scoring.jpg
|
||||
# - seo-recommendations.jpg
|
||||
# - seo-apply.jpg
|
||||
# - seo-keywords.jpg
|
||||
# - metadata-comprehensive.jpg
|
||||
# - metadata-social.jpg
|
||||
# - metadata-schema.jpg
|
||||
# - metadata-export.jpg
|
||||
# - publish-platforms.jpg
|
||||
# - publish-schedule.jpg
|
||||
# - publish-versions.jpg
|
||||
# - publish-analytics.jpg
|
||||
|
||||
9
frontend/public/videos/.gitkeep
Normal file
9
frontend/public/videos/.gitkeep
Normal file
@@ -0,0 +1,9 @@
|
||||
# Blog Writer Phase Demo Videos
|
||||
# Add your demo videos here:
|
||||
# - phase1-research.mp4
|
||||
# - phase2-outline.mp4
|
||||
# - phase3-content.mp4
|
||||
# - phase4-seo.mp4
|
||||
# - phase5-metadata.mp4
|
||||
# - phase6-publish.mp4
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { debug } from '../../utils/debug';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
|
||||
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../services/blogWriterApi';
|
||||
import { useOutlinePolling, useMediumGenerationPolling, useResearchPolling, useRewritePolling } from '../../hooks/usePolling';
|
||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||
@@ -26,10 +29,16 @@ import OutlineRefiner from './OutlineRefiner';
|
||||
import { SEOProcessor } from './SEO';
|
||||
import BlogWriterLanding from './BlogWriterLanding';
|
||||
import { OutlineProgressModal } from './OutlineProgressModal';
|
||||
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
|
||||
import OutlineFeedbackForm from './OutlineFeedbackForm';
|
||||
import { BlogEditor } from './WYSIWYG';
|
||||
import { SEOAnalysisModal } from './SEOAnalysisModal';
|
||||
import { SEOMetadataModal } from './SEOMetadataModal';
|
||||
import PhaseNavigation from './PhaseNavigation';
|
||||
import { usePhaseNavigation } from '../../hooks/usePhaseNavigation';
|
||||
import HeaderBar from './BlogWriterUtils/HeaderBar';
|
||||
import PhaseContent from './BlogWriterUtils/PhaseContent';
|
||||
import useBlogWriterCopilotActions from './BlogWriterUtils/useBlogWriterCopilotActions';
|
||||
|
||||
// Type assertion for CopilotKit action
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -59,6 +68,7 @@ export const BlogWriter: React.FC = () => {
|
||||
flowAnalysisResults,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
setSelectedTitle,
|
||||
setSections,
|
||||
setSeoAnalysis,
|
||||
setGenMode,
|
||||
@@ -79,6 +89,227 @@ export const BlogWriter: React.FC = () => {
|
||||
handleContentSave
|
||||
} = useBlogWriterState();
|
||||
|
||||
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
|
||||
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
|
||||
const [seoRecommendationsApplied, setSeoRecommendationsApplied] = useState(false);
|
||||
const lastSEOModalOpenRef = useRef<number>(0);
|
||||
|
||||
// Phase navigation hook
|
||||
const {
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
resetUserSelection
|
||||
} = usePhaseNavigation(
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
Object.keys(sections).length > 0,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied
|
||||
);
|
||||
|
||||
// Helper: run same checks as analyzeSEO and open modal
|
||||
const runSEOAnalysisDirect = (): string => {
|
||||
const hasSections = !!sections && Object.keys(sections).length > 0;
|
||||
const hasResearch = !!research && !!(research as any).keyword_analysis;
|
||||
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
|
||||
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
|
||||
// Prevent rapid re-opens
|
||||
const now = Date.now();
|
||||
if (isSEOAnalysisModalOpen && now - lastSEOModalOpenRef.current < 1000) {
|
||||
return "SEO analysis is already open.";
|
||||
}
|
||||
|
||||
// Mark content phase as done when user clicks "Next: Run SEO Analysis"
|
||||
if (!contentConfirmed) {
|
||||
setContentConfirmed(true);
|
||||
debug.log('[BlogWriter] Content phase marked as done (SEO analysis triggered)');
|
||||
}
|
||||
|
||||
setSeoRecommendationsApplied(false);
|
||||
if (!isSEOAnalysisModalOpen) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
lastSEOModalOpenRef.current = now;
|
||||
debug.log('[BlogWriter] SEO modal opened (direct)');
|
||||
}
|
||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
||||
};
|
||||
|
||||
const handleApplySeoRecommendations = useCallback(async (
|
||||
recommendations: BlogSEOActionableRecommendation[]
|
||||
) => {
|
||||
if (!outline || outline.length === 0) {
|
||||
throw new Error('An outline is required before applying recommendations.');
|
||||
}
|
||||
|
||||
const sectionPayload = outline.map((section) => ({
|
||||
id: section.id,
|
||||
heading: section.heading,
|
||||
content: sections[section.id] ?? '',
|
||||
}));
|
||||
|
||||
const response = await blogWriterApi.applySeoRecommendations({
|
||||
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
|
||||
sections: sectionPayload,
|
||||
outline,
|
||||
research: (research as any) || {},
|
||||
recommendations,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to apply recommendations.');
|
||||
}
|
||||
|
||||
if (!response.sections || !Array.isArray(response.sections)) {
|
||||
throw new Error('Recommendation response did not include updated sections.');
|
||||
}
|
||||
|
||||
// Update sections - create new object reference to trigger React re-render
|
||||
const newSections: Record<string, string> = {};
|
||||
response.sections.forEach((section) => {
|
||||
if (section.id && section.content) {
|
||||
newSections[section.id] = section.content;
|
||||
}
|
||||
});
|
||||
|
||||
// Validate we have sections before updating
|
||||
if (Object.keys(newSections).length === 0) {
|
||||
throw new Error('No valid sections received from SEO recommendations application.');
|
||||
}
|
||||
|
||||
// Validate sections have actual content
|
||||
const sectionsWithContent = Object.values(newSections).filter(c => c && c.trim().length > 0);
|
||||
if (sectionsWithContent.length === 0) {
|
||||
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
|
||||
}
|
||||
|
||||
// Log detailed section info for debugging
|
||||
const sectionIds = Object.keys(newSections);
|
||||
const sectionSizes = sectionIds.map(id => ({ id, length: newSections[id]?.length || 0 }));
|
||||
debug.log('[BlogWriter] Applied SEO recommendations: sections updated', {
|
||||
sectionCount: sectionIds.length,
|
||||
sectionsWithContent: sectionsWithContent.length,
|
||||
sectionIds: sectionIds,
|
||||
sectionSizes: sectionSizes,
|
||||
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
|
||||
});
|
||||
|
||||
// Update sections state
|
||||
setSections(newSections);
|
||||
|
||||
// Force a delay to ensure React processes the state update before proceeding
|
||||
// This gives React time to re-render with new sections before phase navigation checks
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
setContinuityRefresh(Date.now());
|
||||
setFlowAnalysisCompleted(false);
|
||||
setFlowAnalysisResults(null);
|
||||
|
||||
if (response.title && response.title !== selectedTitle) {
|
||||
setSelectedTitle(response.title);
|
||||
}
|
||||
|
||||
if (response.applied) {
|
||||
setSeoAnalysis(prev => prev ? { ...prev, applied_recommendations: response.applied } : prev);
|
||||
debug.log('[BlogWriter] SEO analysis state updated with applied recommendations');
|
||||
}
|
||||
|
||||
// Mark recommendations as applied (this will trigger phase navigation check)
|
||||
// But we'll stay in SEO phase to show updated content
|
||||
setSeoRecommendationsApplied(true);
|
||||
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content
|
||||
// Force navigation to SEO phase if we're not already there (safeguard)
|
||||
if (currentPhase !== 'seo') {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced navigation to SEO phase after applying recommendations');
|
||||
} else {
|
||||
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
|
||||
}
|
||||
}, [outline, sections, selectedTitle, research, setSections, setSelectedTitle, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSeoAnalysis, currentPhase, navigateToPhase]);
|
||||
|
||||
// Handle SEO analysis completion
|
||||
const handleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||
setSeoAnalysis(analysis);
|
||||
debug.log('[BlogWriter] SEO analysis completed', { hasAnalysis: !!analysis });
|
||||
}, [setSeoAnalysis]);
|
||||
|
||||
// Handle SEO modal close - mark SEO phase as done if not already marked
|
||||
const handleSEOModalClose = useCallback(() => {
|
||||
// Mark SEO phase as done when modal closes (even without applying recommendations)
|
||||
if (!seoAnalysis) {
|
||||
// Set a minimal valid seoAnalysis object to mark phase as complete
|
||||
setSeoAnalysis({
|
||||
success: true,
|
||||
overall_score: 0,
|
||||
category_scores: {},
|
||||
analysis_summary: {
|
||||
overall_grade: 'N/A',
|
||||
status: 'Skipped',
|
||||
strongest_category: 'N/A',
|
||||
weakest_category: 'N/A',
|
||||
key_strengths: [],
|
||||
key_weaknesses: [],
|
||||
ai_summary: 'SEO analysis was skipped by user'
|
||||
},
|
||||
actionable_recommendations: [],
|
||||
generated_at: new Date().toISOString()
|
||||
});
|
||||
debug.log('[BlogWriter] SEO phase marked as done (modal closed without analysis)');
|
||||
}
|
||||
setIsSEOAnalysisModalOpen(false);
|
||||
debug.log('[BlogWriter] SEO modal closed');
|
||||
}, [seoAnalysis, setSeoAnalysis, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
// Mark SEO phase as completed when recommendations are applied
|
||||
useEffect(() => {
|
||||
if (seoRecommendationsApplied && seoAnalysis) {
|
||||
// SEO phase is considered complete when recommendations are applied
|
||||
// But stay in SEO phase to show updated content
|
||||
debug.log('[BlogWriter] SEO recommendations applied, SEO phase marked as complete');
|
||||
|
||||
// Ensure we stay in SEO phase to show updated content (override auto-progression)
|
||||
if (currentPhase !== 'seo' && Object.keys(sections).length > 0) {
|
||||
navigateToPhase('seo');
|
||||
debug.log('[BlogWriter] Forced stay in SEO phase to show updated content');
|
||||
}
|
||||
}
|
||||
}, [seoRecommendationsApplied, seoAnalysis, currentPhase, sections, navigateToPhase]);
|
||||
|
||||
// Track when outlines/content become available for the first time
|
||||
const prevOutlineLenRef = useRef<number>(outline.length);
|
||||
const prevOutlineConfirmedRef = useRef<boolean>(outlineConfirmed);
|
||||
const prevContentConfirmedRef = useRef<boolean>(contentConfirmed);
|
||||
|
||||
useEffect(() => {
|
||||
const prevLen = prevOutlineLenRef.current;
|
||||
if (research && prevLen === 0 && outline.length > 0) {
|
||||
resetUserSelection();
|
||||
}
|
||||
prevOutlineLenRef.current = outline.length;
|
||||
}, [research, outline.length, resetUserSelection]);
|
||||
|
||||
// Only reset user selection when transitioning from not-confirmed to confirmed
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevOutlineConfirmedRef.current;
|
||||
if (!wasConfirmed && outlineConfirmed && Object.keys(sections).length > 0) {
|
||||
resetUserSelection(); // Allow auto-progression to content phase
|
||||
}
|
||||
prevOutlineConfirmedRef.current = outlineConfirmed;
|
||||
}, [outlineConfirmed, sections, resetUserSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasConfirmed = prevContentConfirmedRef.current;
|
||||
if (!wasConfirmed && contentConfirmed && seoAnalysis) {
|
||||
resetUserSelection(); // Allow auto-progression to SEO phase
|
||||
}
|
||||
prevContentConfirmedRef.current = contentConfirmed;
|
||||
}, [contentConfirmed, seoAnalysis, resetUserSelection]);
|
||||
|
||||
// Custom hooks for complex functionality
|
||||
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
|
||||
outline,
|
||||
@@ -139,28 +370,63 @@ export const BlogWriter: React.FC = () => {
|
||||
onError: (err) => console.error('Rewrite 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 },
|
||||
Object.keys(sections).length > 0, // hasContent
|
||||
flowAnalysisCompleted, // flowAnalysisCompleted state
|
||||
contentConfirmed // contentConfirmed state
|
||||
);
|
||||
|
||||
// Add minimum display time for modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
|
||||
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
|
||||
const [showOutlineModal, setShowOutlineModal] = useState(false);
|
||||
|
||||
// SEO Analysis Modal state
|
||||
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
|
||||
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
|
||||
const suggestions = useSuggestions({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPolling: { isPolling: researchPolling.isPolling, currentStatus: researchPolling.currentStatus },
|
||||
outlinePolling: { isPolling: outlinePolling.isPolling, currentStatus: outlinePolling.currentStatus },
|
||||
mediumPolling: { isPolling: mediumPolling.isPolling, currentStatus: mediumPolling.currentStatus },
|
||||
hasContent: Object.keys(sections).length > 0,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied,
|
||||
});
|
||||
|
||||
// Drive CopilotKit suggestions programmatically
|
||||
const copilotHeadless = (useCopilotChatHeadless_c as any)?.();
|
||||
const setSuggestionsRef = useRef<any>(null);
|
||||
useEffect(() => {
|
||||
setSuggestionsRef.current = copilotHeadless?.setSuggestions;
|
||||
}, [copilotHeadless]);
|
||||
|
||||
const suggestionsPayload = React.useMemo(
|
||||
() => (Array.isArray(suggestions) ? suggestions.map((s: any) => ({ title: s.title, message: s.message })) : []),
|
||||
[suggestions]
|
||||
);
|
||||
const prevSuggestionsRef = useRef<string>("__init__");
|
||||
const suggestionsJson = React.useMemo(() => JSON.stringify(suggestionsPayload), [suggestionsPayload]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!setSuggestionsRef.current) return;
|
||||
if (suggestionsJson !== prevSuggestionsRef.current) {
|
||||
setSuggestionsRef.current(suggestionsPayload);
|
||||
debug.log('[BlogWriter] Copilot suggestions pushed', { count: suggestionsPayload.length });
|
||||
prevSuggestionsRef.current = suggestionsJson;
|
||||
}
|
||||
} catch {}
|
||||
}, [suggestionsJson, suggestionsPayload]);
|
||||
|
||||
const handlePhaseClick = useCallback((phaseId: string) => {
|
||||
navigateToPhase(phaseId);
|
||||
if (phaseId === 'seo') {
|
||||
if (seoAnalysis) {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
debug.log('[BlogWriter] SEO modal opened (phase navigation)');
|
||||
} else {
|
||||
runSEOAnalysisDirect();
|
||||
}
|
||||
}
|
||||
}, [navigateToPhase, seoAnalysis, runSEOAnalysisDirect]);
|
||||
const outlineGenRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
||||
@@ -214,96 +480,73 @@ export const BlogWriter: React.FC = () => {
|
||||
progressCount: mediumPolling.progressMessages.length
|
||||
});
|
||||
|
||||
// Debug SEO modal state
|
||||
console.log('🔍 SEO Analysis Modal state:', {
|
||||
isSEOAnalysisModalOpen,
|
||||
hasResearch: !!research,
|
||||
hasContent: !!sections && Object.keys(sections).length > 0,
|
||||
researchKeys: research ? Object.keys(research) : [],
|
||||
sectionsKeys: sections ? Object.keys(sections) : []
|
||||
});
|
||||
// Log critical state changes only (reduce noise)
|
||||
const lastPhaseRef = useRef<string>('');
|
||||
const lastSeoOpenRef = useRef<boolean>(false);
|
||||
const lastSectionsLenRef = useRef<number>(0);
|
||||
|
||||
// Debug action registration
|
||||
console.log('📋 CopilotKit Actions Registered:', ['confirmBlogContent', 'analyzeSEO']);
|
||||
|
||||
// Copilot action for confirming blog content
|
||||
useCopilotActionTyped({
|
||||
name: "confirmBlogContent",
|
||||
description: "Confirm that the blog content is ready and move to the next stage (SEO analysis)",
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
console.log('Blog content confirmed by user');
|
||||
setContentConfirmed(true);
|
||||
return "Blog content has been confirmed! You can now proceed with SEO analysis and publishing.";
|
||||
useEffect(() => {
|
||||
if (currentPhase !== lastPhaseRef.current) {
|
||||
debug.log('[BlogWriter] Phase changed', { currentPhase });
|
||||
lastPhaseRef.current = currentPhase;
|
||||
}
|
||||
});
|
||||
}, [currentPhase]);
|
||||
|
||||
// Copilot action for running SEO analysis
|
||||
useCopilotActionTyped({
|
||||
name: "analyzeSEO",
|
||||
description: "Analyze the blog content for SEO optimization and provide detailed recommendations",
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
console.log('🚀 SEO Analysis Action Triggered!');
|
||||
console.log('Current modal state before:', isSEOAnalysisModalOpen);
|
||||
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
|
||||
console.log('Research data available:', !!research && !!research.keyword_analysis);
|
||||
|
||||
// Check if we have content to analyze
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
console.log('❌ No content available for SEO analysis');
|
||||
return "No blog content available for SEO analysis. Please generate content first.";
|
||||
useEffect(() => {
|
||||
const open = isSEOAnalysisModalOpen;
|
||||
if (open !== lastSeoOpenRef.current) {
|
||||
debug.log('[BlogWriter] SEO modal', { isOpen: open });
|
||||
lastSeoOpenRef.current = open;
|
||||
}
|
||||
}, [isSEOAnalysisModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const len = Object.keys(sections || {}).length;
|
||||
if (len !== lastSectionsLenRef.current) {
|
||||
debug.log('[BlogWriter] Sections updated', { count: len });
|
||||
lastSectionsLenRef.current = len;
|
||||
}
|
||||
}, [sections]);
|
||||
|
||||
useEffect(() => {
|
||||
debug.log('[BlogWriter] Suggestions updated', { suggestions });
|
||||
}, [suggestions]);
|
||||
|
||||
// Force-sync Copilot suggestions right after SEO recommendations applied (guarded by previous suggestions key)
|
||||
useEffect(() => {
|
||||
if (!seoAnalysis || !seoRecommendationsApplied || !setSuggestionsRef.current) return;
|
||||
try {
|
||||
if (suggestionsJson !== prevSuggestionsRef.current) {
|
||||
setSuggestionsRef.current(suggestionsPayload);
|
||||
debug.log('[BlogWriter] Forced Copilot suggestions sync after SEO recommendations applied', { count: suggestionsPayload.length });
|
||||
prevSuggestionsRef.current = suggestionsJson;
|
||||
}
|
||||
|
||||
// Check if we have research data
|
||||
if (!research || !research.keyword_analysis) {
|
||||
console.log('❌ No research data available for SEO analysis');
|
||||
return "Research data is required for SEO analysis. Please run research first.";
|
||||
}
|
||||
|
||||
// Open SEO analysis modal
|
||||
console.log('✅ All checks passed, opening SEO analysis modal');
|
||||
} catch (e) {
|
||||
console.error('Failed to push Copilot suggestions after SEO apply:', e);
|
||||
}
|
||||
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
|
||||
|
||||
const confirmBlogContentCb = useCallback(() => {
|
||||
debug.log('[BlogWriter] Blog content confirmed by user');
|
||||
setContentConfirmed(true);
|
||||
resetUserSelection();
|
||||
setSeoRecommendationsApplied(false);
|
||||
navigateToPhase('seo');
|
||||
setTimeout(() => {
|
||||
setIsSEOAnalysisModalOpen(true);
|
||||
console.log('Modal state set to true');
|
||||
|
||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
||||
}
|
||||
});
|
||||
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
|
||||
}, 0);
|
||||
return "✅ Blog content has been confirmed! Running SEO analysis now.";
|
||||
}, [setContentConfirmed, resetUserSelection, navigateToPhase, setIsSEOAnalysisModalOpen]);
|
||||
|
||||
// Generate SEO Metadata Action
|
||||
useCopilotActionTyped({
|
||||
name: "generateSEOMetadata",
|
||||
description: "Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data",
|
||||
parameters: [
|
||||
{
|
||||
name: "title",
|
||||
type: "string",
|
||||
description: "Optional blog title to use for metadata generation",
|
||||
required: false
|
||||
}
|
||||
],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
console.log('🚀 Generate SEO Metadata Action Triggered!');
|
||||
console.log('Title provided:', title);
|
||||
console.log('Selected title:', selectedTitle);
|
||||
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
|
||||
console.log('Research data available:', !!research && !!research.keyword_analysis);
|
||||
|
||||
// Check if we have content to generate metadata for
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return "Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.";
|
||||
}
|
||||
|
||||
if (!research || !research.keyword_analysis) {
|
||||
return "Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.";
|
||||
}
|
||||
|
||||
// Open the SEO metadata modal
|
||||
setIsSEOMetadataModalOpen(true);
|
||||
console.log('SEO Metadata modal opened');
|
||||
|
||||
return "Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.";
|
||||
}
|
||||
useBlogWriterCopilotActions({
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent: confirmBlogContentCb,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||
});
|
||||
|
||||
|
||||
@@ -366,6 +609,7 @@ export const BlogWriter: React.FC = () => {
|
||||
|
||||
{/* New extracted functionality components */}
|
||||
<OutlineGenerator
|
||||
ref={outlineGenRef}
|
||||
research={research}
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
@@ -395,241 +639,70 @@ export const BlogWriter: React.FC = () => {
|
||||
{!research ? (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// This will trigger the copilot to start the research process
|
||||
// The user can then interact with the copilot to begin research
|
||||
// Trigger the copilot to start the research process
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
|
||||
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{research && outline.length === 0 && <ResearchResults research={research} />}
|
||||
{outline.length > 0 && (
|
||||
<div>
|
||||
{outlineConfirmed ? (
|
||||
/* WYSIWYG Editor - Show when outline is confirmed */
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
) : (
|
||||
/* 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))}
|
||||
/>
|
||||
|
||||
{/* 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} />
|
||||
)}
|
||||
</div>
|
||||
{sections[s.id] ? (
|
||||
<>
|
||||
<pre style={{ whiteSpace: 'pre-wrap' }}>{sections[s.id]}</pre>
|
||||
<SEOMiniPanel analysis={seoAnalysis} />
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontStyle: 'italic', color: '#666' }}>Ask the copilot to generate this section.</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<HeaderBar
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
/>
|
||||
<PhaseContent
|
||||
currentPhase={currentPhase}
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
setOutline={setOutline}
|
||||
sections={sections}
|
||||
handleContentUpdate={handleContentUpdate}
|
||||
handleContentSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
outlineGenRef={outlineGenRef}
|
||||
blogWriterApi={blogWriterApi}
|
||||
contentConfirmed={contentConfirmed}
|
||||
seoAnalysis={seoAnalysis}
|
||||
seoMetadata={seoMetadata}
|
||||
onTitleSelect={handleTitleSelect}
|
||||
onCustomTitle={handleCustomTitle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.'
|
||||
}}
|
||||
<WriterCopilotSidebar
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
// 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,
|
||||
angles: research.suggested_angles?.length || 0,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
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.
|
||||
|
||||
CURRENT STATE:
|
||||
${hasResearch && researchInfo ? `
|
||||
✅ RESEARCH COMPLETED:
|
||||
- Found ${researchInfo.sources} sources with Google Search grounding
|
||||
- Generated ${researchInfo.queries} search queries
|
||||
- Created ${researchInfo.angles} content angles
|
||||
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
|
||||
${outlineContext}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
|
||||
- researchTopic(keywords: string, industry?: string, target_audience?: string)
|
||||
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
|
||||
- generateOutline()
|
||||
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
|
||||
- refineOutline(prompt?: string) - Refine outline based on user feedback
|
||||
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
|
||||
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
||||
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
|
||||
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
|
||||
- generateSEOMetadata(title?: string)
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR & USER GUIDANCE:
|
||||
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
|
||||
- When user asks to research something, call getResearchKeywords() first to collect their keywords
|
||||
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
|
||||
|
||||
USER GUIDANCE STRATEGY:
|
||||
- After research completion, ALWAYS guide user toward outline creation as the next step
|
||||
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
|
||||
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
|
||||
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
|
||||
- After outline generation, ALWAYS guide user to review and confirm the outline
|
||||
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
|
||||
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
|
||||
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
|
||||
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
|
||||
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
|
||||
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
|
||||
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
|
||||
- After content generation, guide users to review and confirm their content before moving to SEO stage
|
||||
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after content confirmation, show SEO analysis and publishing suggestions
|
||||
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
|
||||
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
|
||||
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
|
||||
|
||||
ENGAGEMENT TACTICS:
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
|
||||
- Use encouraging language and highlight progress made
|
||||
- If user seems lost, remind them of the current stage and suggest the next step
|
||||
- When research is complete, emphasize the value of the data found and guide to outline creation
|
||||
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
|
||||
- Encourage users to make small manual edits to the outline UI before using AI for major changes
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
research={research}
|
||||
outline={outline}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
/>
|
||||
|
||||
{/* Outline Progress Modal */}
|
||||
{/* Outline modal */}
|
||||
<OutlineProgressModal
|
||||
isVisible={showOutlineModal}
|
||||
status={outlinePolling.currentStatus}
|
||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
|
||||
error={outlinePolling.error}
|
||||
/>
|
||||
|
||||
{/* Medium generation / Rewrite modal */}
|
||||
<OutlineProgressModal
|
||||
isVisible={showModal}
|
||||
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
|
||||
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={rewritePolling.isPolling ?
|
||||
(rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : '') :
|
||||
(mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : '')
|
||||
}
|
||||
error={rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error}
|
||||
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
|
||||
<TaskProgressModals
|
||||
showOutlineModal={showOutlineModal}
|
||||
outlinePolling={outlinePolling}
|
||||
showModal={showModal}
|
||||
rewritePolling={rewritePolling}
|
||||
mediumPolling={mediumPolling}
|
||||
/>
|
||||
|
||||
{/* SEO Analysis Modal */}
|
||||
<SEOAnalysisModal
|
||||
isOpen={isSEOAnalysisModalOpen}
|
||||
onClose={() => setIsSEOAnalysisModalOpen(false)}
|
||||
onClose={handleSEOModalClose}
|
||||
blogContent={buildFullMarkdown()}
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
onApplyRecommendations={(recommendations) => {
|
||||
console.log('Applying SEO recommendations:', recommendations);
|
||||
// TODO: Implement recommendation application logic
|
||||
}}
|
||||
onApplyRecommendations={handleApplySeoRecommendations}
|
||||
onAnalysisComplete={handleSEOAnalysisComplete}
|
||||
/>
|
||||
|
||||
{/* SEO Metadata Modal */}
|
||||
@@ -639,10 +712,14 @@ Available tools:
|
||||
blogContent={buildFullMarkdown()}
|
||||
blogTitle={selectedTitle}
|
||||
researchData={research}
|
||||
outline={outline}
|
||||
seoAnalysis={seoAnalysis}
|
||||
onMetadataGenerated={(metadata) => {
|
||||
console.log('SEO metadata generated:', metadata);
|
||||
setSeoMetadata(metadata);
|
||||
// TODO: Implement metadata application logic
|
||||
// Metadata is now saved and will be used when publishing to WordPress/Wix
|
||||
// The metadata includes all SEO fields (title, description, tags, Open Graph, etc.)
|
||||
// Publisher component will use this metadata when calling publish API
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
|
||||
import BlogWriterPhasesSection from './BlogWriterPhasesSection';
|
||||
|
||||
interface BlogWriterLandingProps {
|
||||
onStartWriting: () => void;
|
||||
@@ -198,7 +199,7 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SuperPowers Modal */}
|
||||
{/* SuperPowers Modal with 6 Phases */}
|
||||
{showSuperPowers && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
@@ -206,20 +207,18 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
zIndex: 1000,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '20px',
|
||||
padding: '40px',
|
||||
maxWidth: '900px',
|
||||
width: '90%',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
maxWidth: '1400px',
|
||||
minHeight: '100%',
|
||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
|
||||
}}>
|
||||
{/* Modal Header */}
|
||||
@@ -271,69 +270,82 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SuperPowers Grid */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
||||
gap: '24px'
|
||||
}}>
|
||||
{superPowers.map((power, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '24px',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{/* 6 Phases Section */}
|
||||
<BlogWriterPhasesSection />
|
||||
|
||||
{/* Quick SuperPowers Grid */}
|
||||
<div style={{ padding: '40px', borderTop: '1px solid #f0f0f0' }}>
|
||||
<h2 style={{
|
||||
margin: '0 0 20px 0',
|
||||
fontSize: '1.5rem',
|
||||
textAlign: 'center',
|
||||
color: '#333'
|
||||
}}>
|
||||
Quick Feature Overview
|
||||
</h2>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '20px'
|
||||
}}>
|
||||
{superPowers.map((power, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
||||
e.currentTarget.style.borderColor = '#1976d2';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '2rem',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
||||
borderRadius: '12px'
|
||||
gap: '16px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
{power.icon}
|
||||
<div style={{
|
||||
fontSize: '2rem',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
||||
borderRadius: '12px'
|
||||
}}>
|
||||
{power.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '1.1rem',
|
||||
color: '#333',
|
||||
fontWeight: '600'
|
||||
}}>
|
||||
{power.title}
|
||||
</h3>
|
||||
</div>
|
||||
<h3 style={{
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: '1.3rem',
|
||||
color: '#333',
|
||||
fontWeight: '600'
|
||||
color: '#666',
|
||||
lineHeight: '1.6',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
{power.title}
|
||||
</h3>
|
||||
{power.description}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
color: '#666',
|
||||
lineHeight: '1.6',
|
||||
fontSize: '1rem'
|
||||
}}>
|
||||
{power.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
|
||||
576
frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
Normal file
576
frontend/src/components/BlogWriter/BlogWriterPhasesSection.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Container, Grid, Card, CardContent, Typography, Box, Stack, Chip } from '@mui/material';
|
||||
import { CheckCircle, AutoAwesome } from '@mui/icons-material';
|
||||
|
||||
interface PhaseFeature {
|
||||
title: string;
|
||||
description: string;
|
||||
details: string[];
|
||||
imagePlaceholder: string;
|
||||
}
|
||||
|
||||
interface BlogPhase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
shortDescription: string;
|
||||
features: PhaseFeature[];
|
||||
technicalDetails: {
|
||||
aiModel: string;
|
||||
promptType: string;
|
||||
outputFormat: string;
|
||||
integration: string;
|
||||
};
|
||||
videoPlaceholder: string;
|
||||
}
|
||||
|
||||
const BlogWriterPhasesSection: React.FC = () => {
|
||||
const [activePhase, setActivePhase] = useState<number | null>(null);
|
||||
|
||||
const phases: BlogPhase[] = [
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Research & Strategy',
|
||||
icon: '🔍',
|
||||
shortDescription: 'AI-powered comprehensive research with Google Search grounding, competitor analysis, and content gap identification',
|
||||
features: [
|
||||
{
|
||||
title: 'Google Search Grounding',
|
||||
description: 'Real-time web research using Gemini\'s native Google Search integration',
|
||||
details: [
|
||||
'Single API call for comprehensive research',
|
||||
'Live web data from credible sources',
|
||||
'Automatic source extraction and citation',
|
||||
'Current trends and 2024-2025 insights',
|
||||
'Market analysis and forecasts'
|
||||
],
|
||||
imagePlaceholder: '/images/research-google-grounding.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Competitor Analysis',
|
||||
description: 'Identify top players and content opportunities in your niche',
|
||||
details: [
|
||||
'Top competitor content analysis',
|
||||
'Content gap identification',
|
||||
'Unique angle discovery',
|
||||
'Market positioning insights',
|
||||
'Competitive advantage opportunities'
|
||||
],
|
||||
imagePlaceholder: '/images/research-competitor.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Keyword Intelligence',
|
||||
description: 'Comprehensive keyword analysis with SEO opportunities',
|
||||
details: [
|
||||
'Primary, secondary, and long-tail keyword identification',
|
||||
'Search volume and competition analysis',
|
||||
'Keyword clustering and grouping',
|
||||
'Content optimization suggestions',
|
||||
'Target audience keyword mapping'
|
||||
],
|
||||
imagePlaceholder: '/images/research-keywords.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Content Angle Generation',
|
||||
description: 'AI-generated compelling content angles for maximum engagement',
|
||||
details: [
|
||||
'5 unique content angle suggestions',
|
||||
'Trending topic identification',
|
||||
'Audience pain point mapping',
|
||||
'Viral potential assessment',
|
||||
'Expert opinion synthesis'
|
||||
],
|
||||
imagePlaceholder: '/images/research-angles.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Gemini Pro with Google Search Grounding',
|
||||
promptType: 'Comprehensive research prompt',
|
||||
outputFormat: 'Structured JSON with sources, keywords, trends, competitors',
|
||||
integration: 'GeminiGroundedProvider via research_service.py'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase1-research.mp4'
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Intelligent Outline',
|
||||
icon: '📝',
|
||||
shortDescription: 'AI-generated outlines with source mapping, grounding insights, and optimization recommendations',
|
||||
features: [
|
||||
{
|
||||
title: 'AI Outline Generation',
|
||||
description: 'Comprehensive outline based on research with SEO optimization',
|
||||
details: [
|
||||
'Section-by-section breakdown',
|
||||
'Subheadings and key points',
|
||||
'Target word counts per section',
|
||||
'Logical flow and progression',
|
||||
'SEO-optimized structure'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-generation.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Source Mapping & Grounding',
|
||||
description: 'Connect each section to research sources with citations',
|
||||
details: [
|
||||
'Automatic source-to-section mapping',
|
||||
'Grounding support scores',
|
||||
'Citation suggestions',
|
||||
'Source credibility ratings',
|
||||
'Reference verification'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-grounding.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Interactive Refinement',
|
||||
description: 'Human-in-the-loop editing with AI assistance',
|
||||
details: [
|
||||
'Add, remove, merge sections',
|
||||
'Reorder and restructure',
|
||||
'AI enhancement suggestions',
|
||||
'Custom instructions support',
|
||||
'Multiple outline versions'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-refine.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Title Generation',
|
||||
description: 'Multiple SEO-optimized title options',
|
||||
details: [
|
||||
'AI-generated title variations',
|
||||
'SEO score per title',
|
||||
'Engagement potential analysis',
|
||||
'Keyword integration',
|
||||
'Click-through optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/outline-titles.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Gemini Pro (provider-agnostic via llm_text_gen)',
|
||||
promptType: 'Structured outline prompt with research context',
|
||||
outputFormat: 'JSON outline with sections, headings, key_points, references',
|
||||
integration: 'OutlineService via parallel_processor.py'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase2-outline.mp4'
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
name: 'Content Generation',
|
||||
icon: '✨',
|
||||
shortDescription: 'Section-by-section content generation with SEO optimization, context memory, and engagement improvements',
|
||||
features: [
|
||||
{
|
||||
title: 'Smart Content Generation',
|
||||
description: 'AI-powered section writing with context awareness',
|
||||
details: [
|
||||
'Section-by-section generation',
|
||||
'Context memory across sections',
|
||||
'Smooth transitions between sections',
|
||||
'Consistent tone and style',
|
||||
'Natural keyword integration'
|
||||
],
|
||||
imagePlaceholder: '/images/content-generation.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Continuity Analysis',
|
||||
description: 'Real-time flow and coherence monitoring',
|
||||
details: [
|
||||
'Narrative flow assessment',
|
||||
'Coherence scoring',
|
||||
'Transition quality analysis',
|
||||
'Tone consistency tracking',
|
||||
'Content quality metrics'
|
||||
],
|
||||
imagePlaceholder: '/images/content-continuity.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Source Integration',
|
||||
description: 'Automatic citation and source reference',
|
||||
details: [
|
||||
'Relevant URL selection',
|
||||
'Natural citation insertion',
|
||||
'Source attribution',
|
||||
'Evidence-backed content',
|
||||
'Reference management'
|
||||
],
|
||||
imagePlaceholder: '/images/content-sources.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Medium Blog Mode',
|
||||
description: 'Quick generation for Medium-style articles',
|
||||
details: [
|
||||
'Single-call full blog generation',
|
||||
'Medium-optimized formatting',
|
||||
'Engagement-focused structure',
|
||||
'SEO-ready output',
|
||||
'Fast turnaround option'
|
||||
],
|
||||
imagePlaceholder: '/images/content-medium.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Provider-agnostic (Gemini/HF via main_text_generation)',
|
||||
promptType: 'Context-aware section prompt with research',
|
||||
outputFormat: 'Markdown content with transitions and metrics',
|
||||
integration: 'EnhancedContentGenerator with ContextMemory'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase3-content.mp4'
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
name: 'SEO Analysis',
|
||||
icon: '📈',
|
||||
shortDescription: 'Advanced SEO analysis with actionable recommendations and AI-powered optimization',
|
||||
features: [
|
||||
{
|
||||
title: 'Comprehensive SEO Scoring',
|
||||
description: 'Multi-dimensional SEO analysis across key factors',
|
||||
details: [
|
||||
'Overall SEO score (0-100)',
|
||||
'Structure optimization score',
|
||||
'Keyword optimization rating',
|
||||
'Readability assessment',
|
||||
'Quality metrics evaluation'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-scoring.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Actionable Recommendations',
|
||||
description: 'AI-powered improvement suggestions',
|
||||
details: [
|
||||
'Priority-ranked fixes',
|
||||
'Specific text improvements',
|
||||
'Keyword density optimization',
|
||||
'Heading structure suggestions',
|
||||
'Content enhancement ideas'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-recommendations.jpg'
|
||||
},
|
||||
{
|
||||
title: 'AI-Powered Content Refinement',
|
||||
description: 'Automatically apply SEO recommendations',
|
||||
details: [
|
||||
'Smart content rewriting',
|
||||
'Preserves original intent',
|
||||
'Natural keyword integration',
|
||||
'Readability improvement',
|
||||
'Structure optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-apply.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Keyword Analysis',
|
||||
description: 'Deep dive into keyword performance',
|
||||
details: [
|
||||
'Primary keyword density',
|
||||
'Semantic keyword usage',
|
||||
'Long-tail keyword opportunities',
|
||||
'Keyword distribution heatmap',
|
||||
'Optimization recommendations'
|
||||
],
|
||||
imagePlaceholder: '/images/seo-keywords.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Parallel non-AI analyzers + single AI call',
|
||||
promptType: 'Structured SEO analysis prompt',
|
||||
outputFormat: 'Comprehensive SEO report with scores and recommendations',
|
||||
integration: 'BlogContentSEOAnalyzer with parallel processing'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase4-seo.mp4'
|
||||
},
|
||||
{
|
||||
id: 'metadata',
|
||||
name: 'SEO Metadata',
|
||||
icon: '🎯',
|
||||
shortDescription: 'Optimized metadata generation for titles, descriptions, Open Graph, Twitter cards, and structured data',
|
||||
features: [
|
||||
{
|
||||
title: 'Comprehensive Metadata',
|
||||
description: 'All-in-one SEO metadata generation',
|
||||
details: [
|
||||
'SEO-optimized title (50-60 chars)',
|
||||
'Meta description with CTA',
|
||||
'URL slug optimization',
|
||||
'Blog tags and categories',
|
||||
'Social hashtags'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-comprehensive.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Open Graph & Twitter Cards',
|
||||
description: 'Rich social media previews',
|
||||
details: [
|
||||
'OG title and description',
|
||||
'Twitter card optimization',
|
||||
'Image preview settings',
|
||||
'Social engagement boost',
|
||||
'Click-through optimization'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-social.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Structured Data',
|
||||
description: 'Schema.org markup for rich snippets',
|
||||
details: [
|
||||
'Article schema',
|
||||
'Organization markup',
|
||||
'Breadcrumb schema',
|
||||
'FAQ schema support',
|
||||
'Enhanced search results'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-schema.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Multi-Format Output',
|
||||
description: 'Ready-to-use metadata in all formats',
|
||||
details: [
|
||||
'HTML meta tags',
|
||||
'JSON-LD structured data',
|
||||
'WordPress export format',
|
||||
'Wix integration format',
|
||||
'One-click copy options'
|
||||
],
|
||||
imagePlaceholder: '/images/metadata-export.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Maximum 2 AI calls for comprehensive metadata',
|
||||
promptType: 'Personalized metadata prompt with context',
|
||||
outputFormat: 'Complete metadata package (title, desc, tags, schema)',
|
||||
integration: 'BlogSEOMetadataGenerator with optimization'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase5-metadata.mp4'
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
name: 'Publish & Distribute',
|
||||
icon: '🚀',
|
||||
shortDescription: 'Direct publishing to WordPress, Wix, Medium, and other platforms with scheduling',
|
||||
features: [
|
||||
{
|
||||
title: 'Multi-Platform Publishing',
|
||||
description: 'Publish to multiple platforms simultaneously',
|
||||
details: [
|
||||
'WordPress direct publishing',
|
||||
'Wix blog integration',
|
||||
'Medium publishing',
|
||||
'Custom blog platforms',
|
||||
'API integrations'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-platforms.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Content Scheduling',
|
||||
description: 'Schedule posts for optimal timing',
|
||||
details: [
|
||||
'Time-based scheduling',
|
||||
'Timezone management',
|
||||
'Bulk scheduling support',
|
||||
'Calendar integration',
|
||||
'Reminder notifications'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-schedule.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Revision Management',
|
||||
description: 'Track and manage content versions',
|
||||
details: [
|
||||
'Version history',
|
||||
'Change tracking',
|
||||
'Rollback capabilities',
|
||||
'A/B testing support',
|
||||
'Performance comparison'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-versions.jpg'
|
||||
},
|
||||
{
|
||||
title: 'Analytics Integration',
|
||||
description: 'Post-publish performance tracking',
|
||||
details: [
|
||||
'View count tracking',
|
||||
'Engagement metrics',
|
||||
'SEO performance',
|
||||
'Traffic analysis',
|
||||
'Conversion tracking'
|
||||
],
|
||||
imagePlaceholder: '/images/publish-analytics.jpg'
|
||||
}
|
||||
],
|
||||
technicalDetails: {
|
||||
aiModel: 'Platform-specific API integrations',
|
||||
promptType: 'N/A - publishing only',
|
||||
outputFormat: 'Published content with URL',
|
||||
integration: 'Platform APIs via Publisher component'
|
||||
},
|
||||
videoPlaceholder: '/videos/phase6-publish.mp4'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 8, bgcolor: 'background.paper' }}>
|
||||
<Container maxWidth="lg">
|
||||
{/* Section Title */}
|
||||
<Box sx={{ textAlign: 'center', mb: 6 }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
component="h2"
|
||||
sx={{
|
||||
fontSize: { xs: '2rem', md: '3rem' },
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, #1976d2 0%, #9c27b0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
mb: 2
|
||||
}}
|
||||
>
|
||||
Complete AI Blog Writing Workflow
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: '800px', mx: 'auto' }}>
|
||||
Six powerful phases that transform your ideas into SEO-optimized, engaging blog content
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Phase Cards */}
|
||||
<Grid container spacing={4}>
|
||||
{phases.map((phase, index) => (
|
||||
<Grid item xs={12} md={6} key={phase.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s ease',
|
||||
border: activePhase === index ? 2 : 1,
|
||||
borderColor: activePhase === index ? 'primary.main' : 'divider',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
boxShadow: 6,
|
||||
}
|
||||
}}
|
||||
onClick={() => setActivePhase(activePhase === index ? null : index)}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="flex-start" mb={2}>
|
||||
<Typography variant="h2" sx={{ fontSize: '3rem' }}>
|
||||
{phase.icon}
|
||||
</Typography>
|
||||
<Box flex={1}>
|
||||
<Typography variant="h5" fontWeight={600} gutterBottom>
|
||||
{phase.name}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{phase.shortDescription}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
label={`Phase ${index + 1}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{activePhase === index && (
|
||||
<Box sx={{ mt: 3, pt: 3, borderTop: 1, borderColor: 'divider' }}>
|
||||
{/* Video Placeholder */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '16/9',
|
||||
bgcolor: 'grey.200',
|
||||
borderRadius: 2,
|
||||
mb: 3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
🎥 Video: {phase.videoPlaceholder}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Features Grid */}
|
||||
<Grid container spacing={2} mb={3}>
|
||||
{phase.features.map((feature, idx) => (
|
||||
<Grid item xs={12} sm={6} key={idx}>
|
||||
<Card variant="outlined" sx={{ p: 2, height: '100%' }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '4/3',
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: 1,
|
||||
mb: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
📷 Image
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" mb={1}>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{feature.details.slice(0, 3).map((detail, i) => (
|
||||
<Stack key={i} direction="row" spacing={1} alignItems="flex-start">
|
||||
<CheckCircle sx={{ fontSize: 16, color: 'success.main', mt: 0.5 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{detail}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Technical Details */}
|
||||
<Card variant="outlined" sx={{ bgcolor: 'grey.50', p: 2 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600} mb={1} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AutoAwesome sx={{ fontSize: 18 }} />
|
||||
Technical Implementation
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>AI Model</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.aiModel}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Output Format</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.outputFormat}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Prompt Type</Typography>
|
||||
<Typography variant="body2">{phase.technicalDetails.promptType}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Typography variant="caption" fontWeight={600}>Integration</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
{phase.technicalDetails.integration}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriterPhasesSection;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PhaseNavigation from '../PhaseNavigation';
|
||||
|
||||
interface HeaderBarProps {
|
||||
phases: any[];
|
||||
currentPhase: string;
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
}
|
||||
|
||||
export const HeaderBar: React.FC<HeaderBarProps> = ({ phases, currentPhase, onPhaseClick }) => {
|
||||
return (
|
||||
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
|
||||
<div style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666'
|
||||
}}>
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
<PhaseNavigation
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={onPhaseClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OutlineCtaBannerProps {
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
const OutlineCtaBanner: React.FC<OutlineCtaBannerProps> = ({ onGenerate }) => {
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', background: '#fff8e1', borderBottom: '1px solid #ffe0b2', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: '#8d6e63' }}>Next step: generate your outline from research.</span>
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
style={{ padding: '6px 10px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}
|
||||
>
|
||||
Next: Create Outline
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineCtaBanner;
|
||||
@@ -0,0 +1,185 @@
|
||||
import React from 'react';
|
||||
import ResearchResults from '../ResearchResults';
|
||||
import EnhancedTitleSelector from '../EnhancedTitleSelector';
|
||||
import EnhancedOutlineEditor from '../EnhancedOutlineEditor';
|
||||
import { BlogEditor } from '../WYSIWYG';
|
||||
import OutlineCtaBanner from './OutlineCtaBanner';
|
||||
|
||||
interface PhaseContentProps {
|
||||
currentPhase: string;
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
titleOptions: any[];
|
||||
selectedTitle?: string | null;
|
||||
researchTitles: any[];
|
||||
aiGeneratedTitles: any[];
|
||||
sourceMappingStats: any;
|
||||
groundingInsights: any;
|
||||
optimizationResults: any;
|
||||
researchCoverage: any;
|
||||
setOutline: (o: any) => void;
|
||||
sections: Record<string, string>;
|
||||
handleContentUpdate: any;
|
||||
handleContentSave: any;
|
||||
continuityRefresh: number | null;
|
||||
flowAnalysisResults: any;
|
||||
outlineGenRef: React.RefObject<any>;
|
||||
blogWriterApi: any;
|
||||
contentConfirmed: boolean;
|
||||
seoAnalysis: any;
|
||||
seoMetadata: any;
|
||||
onTitleSelect: any;
|
||||
onCustomTitle: any;
|
||||
}
|
||||
|
||||
export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
currentPhase,
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
titleOptions,
|
||||
selectedTitle,
|
||||
researchTitles,
|
||||
aiGeneratedTitles,
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage,
|
||||
setOutline,
|
||||
sections,
|
||||
handleContentUpdate,
|
||||
handleContentSave,
|
||||
continuityRefresh,
|
||||
flowAnalysisResults,
|
||||
outlineGenRef,
|
||||
blogWriterApi,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
onTitleSelect,
|
||||
onCustomTitle
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{currentPhase === 'research' && (
|
||||
<>
|
||||
{research ? (
|
||||
<ResearchResults research={research} />
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Start Your Research</h3>
|
||||
<p>Use the copilot to begin researching your blog topic.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'outline' && research && (
|
||||
<>
|
||||
{outline.length === 0 && (
|
||||
<OutlineCtaBanner onGenerate={() => outlineGenRef.current?.generateNow()} />
|
||||
)}
|
||||
{outline.length > 0 ? (
|
||||
<>
|
||||
<EnhancedTitleSelector
|
||||
titleOptions={titleOptions}
|
||||
selectedTitle={selectedTitle || undefined}
|
||||
sections={outline}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={onTitleSelect}
|
||||
onCustomTitle={onCustomTitle}
|
||||
/>
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
sourceMappingStats={sourceMappingStats}
|
||||
groundingInsights={groundingInsights}
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op: any, id: any, payload: any) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Create Your Outline</h3>
|
||||
<p>Use the copilot to generate an outline based on your research.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'content' && outline.length > 0 && (
|
||||
<>
|
||||
{outlineConfirmed ? (
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Confirm Your Outline</h3>
|
||||
<p>Review and confirm your outline before generating content.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPhase === 'seo' && contentConfirmed && outline.length > 0 && outlineConfirmed && (
|
||||
<>
|
||||
{Object.keys(sections).length > 0 && Object.values(sections).some(content => content && content.trim().length > 0) ? (
|
||||
<BlogEditor
|
||||
outline={outline}
|
||||
research={research}
|
||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
||||
titleOptions={titleOptions}
|
||||
researchTitles={researchTitles}
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
sections={sections}
|
||||
onContentUpdate={handleContentUpdate}
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Loading Content...</h3>
|
||||
<p>Please wait while your content is being optimized.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback for SEO phase if conditions not met */}
|
||||
{currentPhase === 'seo' && (!contentConfirmed || outline.length === 0 || !outlineConfirmed) && (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h3>Optimize your blog for search engines.</h3>
|
||||
<p>Complete the content phase first to enable SEO optimization.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhase === 'publish' && seoAnalysis && seoMetadata && (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>Publish Your Blog</h3>
|
||||
<p>Your blog is ready to publish!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseContent;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { OutlineProgressModal } from '../OutlineProgressModal';
|
||||
|
||||
interface PollingState {
|
||||
isPolling: boolean;
|
||||
currentStatus: string;
|
||||
progressMessages: { message: string }[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface TaskProgressModalsProps {
|
||||
showOutlineModal: boolean;
|
||||
outlinePolling: PollingState;
|
||||
showModal: boolean;
|
||||
rewritePolling: PollingState;
|
||||
mediumPolling: PollingState;
|
||||
}
|
||||
|
||||
const TaskProgressModals: React.FC<TaskProgressModalsProps> = ({
|
||||
showOutlineModal,
|
||||
outlinePolling,
|
||||
showModal,
|
||||
rewritePolling,
|
||||
mediumPolling,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<OutlineProgressModal
|
||||
isVisible={showOutlineModal}
|
||||
status={outlinePolling.currentStatus}
|
||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={outlinePolling.progressMessages.length > 0 ? outlinePolling.progressMessages[outlinePolling.progressMessages.length - 1].message : ''}
|
||||
error={outlinePolling.error ?? null}
|
||||
/>
|
||||
|
||||
<OutlineProgressModal
|
||||
isVisible={showModal}
|
||||
status={rewritePolling.isPolling ? rewritePolling.currentStatus : mediumPolling.currentStatus}
|
||||
progressMessages={rewritePolling.isPolling ? rewritePolling.progressMessages.map(m => m.message) : mediumPolling.progressMessages.map(m => m.message)}
|
||||
latestMessage={rewritePolling.isPolling ? (
|
||||
rewritePolling.progressMessages.length > 0 ? rewritePolling.progressMessages[rewritePolling.progressMessages.length - 1].message : ''
|
||||
) : (
|
||||
mediumPolling.progressMessages.length > 0 ? mediumPolling.progressMessages[mediumPolling.progressMessages.length - 1].message : ''
|
||||
)}
|
||||
error={(rewritePolling.isPolling ? rewritePolling.error : mediumPolling.error) ?? null}
|
||||
titleOverride={rewritePolling.isPolling ? '🔄 Rewriting Your Blog' : '📝 Generating Your Blog Content'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProgressModals;
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
|
||||
interface WriterCopilotSidebarProps {
|
||||
suggestions: any[];
|
||||
research: any;
|
||||
outline: any[];
|
||||
outlineConfirmed: boolean;
|
||||
}
|
||||
|
||||
export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
|
||||
suggestions,
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
}) => {
|
||||
return (
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
const hasResearch = research !== null;
|
||||
const hasOutline = outline.length > 0;
|
||||
const isOutlineConfirmed = outlineConfirmed;
|
||||
const researchInfo = hasResearch
|
||||
? {
|
||||
sources: research.sources?.length || 0,
|
||||
queries: research.search_queries?.length || 0,
|
||||
angles: research.suggested_angles?.length || 0,
|
||||
primaryKeywords: research.keyword_analysis?.primary || [],
|
||||
searchIntent: research.keyword_analysis?.search_intent || 'informational',
|
||||
}
|
||||
: null;
|
||||
|
||||
const outlineContext = hasOutline
|
||||
? `
|
||||
OUTLINE DETAILS:
|
||||
- Total sections: ${outline.length}
|
||||
- Section headings: ${outline.map((s: any) => s.heading).join(', ')}
|
||||
- Total target words: ${outline.reduce((sum: number, s: any) => sum + (s.target_words || 0), 0)}
|
||||
- Section breakdown: ${outline
|
||||
.map(
|
||||
(s: any) => `${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.
|
||||
|
||||
CURRENT STATE:
|
||||
${hasResearch && researchInfo ? `
|
||||
✅ RESEARCH COMPLETED:
|
||||
- Found ${researchInfo.sources} sources with Google Search grounding
|
||||
- Generated ${researchInfo.queries} search queries
|
||||
- Created ${researchInfo.angles} content angles
|
||||
- Primary keywords: ${researchInfo.primaryKeywords.join(', ')}
|
||||
- Search intent: ${researchInfo.searchIntent}
|
||||
` : '❌ No research completed yet'}
|
||||
|
||||
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
|
||||
${outlineContext}
|
||||
|
||||
Available tools:
|
||||
- getResearchKeywords(prompt?: string) - Get keywords from user for research
|
||||
- performResearch(formData: string) - Perform research with collected keywords (formData is JSON string with keywords and blogLength)
|
||||
- researchTopic(keywords: string, industry?: string, target_audience?: string)
|
||||
- chatWithResearchData(question: string) - Chat with research data to explore insights and get recommendations
|
||||
- generateOutline()
|
||||
- createOutlineWithCustomInputs(customInstructions: string) - Create outline with user's custom instructions
|
||||
- refineOutline(prompt?: string) - Refine outline based on user feedback
|
||||
- chatWithOutline(question?: string) - Chat with outline to get insights and ask questions about content structure
|
||||
- confirmOutlineAndGenerateContent() - Confirm outline and mark as ready for content generation (does NOT auto-generate content)
|
||||
- generateSection(sectionId: string)
|
||||
- generateAllSections()
|
||||
- refineOutlineStructure(operation: add|remove|move|merge|rename, sectionId?: string, payload?: object)
|
||||
- enhanceSection(sectionId: string, focus?: string) - Enhance a specific section with AI improvements
|
||||
- optimizeOutline(focus?: string) - Optimize entire outline for better flow, SEO, and engagement
|
||||
- rebalanceOutline(targetWords?: number) - Rebalance word count distribution across sections
|
||||
- confirmBlogContent() - Confirm that blog content is ready and move to SEO stage
|
||||
- analyzeSEO() - Analyze SEO for blog content with comprehensive insights and visual interface
|
||||
- generateSEOMetadata(title?: string)
|
||||
- publishToPlatform(platform: 'wix'|'wordpress', schedule_time?: string)
|
||||
|
||||
CRITICAL BEHAVIOR & USER GUIDANCE:
|
||||
- When user wants to research ANY topic, IMMEDIATELY call getResearchKeywords() to get their input
|
||||
- When user asks to research something, call getResearchKeywords() first to collect their keywords
|
||||
- After getResearchKeywords() completes, IMMEDIATELY call performResearch() with the collected data
|
||||
|
||||
USER GUIDANCE STRATEGY:
|
||||
- If the user's last message EXACTLY matches an available tool name (e.g., generateOutline, confirmOutlineAndGenerateContent, confirmBlogContent, analyzeSEO), IMMEDIATELY call that tool with default arguments and WITHOUT any additional questions or confirmations
|
||||
- After research completion, ALWAYS guide user toward outline creation as the next step
|
||||
- If user wants to explore research data, use chatWithResearchData() but then guide them to outline creation
|
||||
- If user has specific outline requirements, use createOutlineWithCustomInputs() with their instructions
|
||||
- When user asks for outline, call generateOutline() or createOutlineWithCustomInputs() based on their needs
|
||||
- After outline generation, ALWAYS guide user to review and confirm the outline
|
||||
- If user wants to discuss the outline, use chatWithOutline() to provide insights and answer questions
|
||||
- If user wants to refine the outline, use refineOutline() to collect their feedback and refine
|
||||
- When user says "I confirm the outline" or "I confirm the outline and am ready to generate content" or clicks "Confirm & Generate Content", IMMEDIATELY call confirmOutlineAndGenerateContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms the outline, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after outline confirmation, show content generation suggestions and wait for user to explicitly request content generation
|
||||
- When user asks to generate content before outline confirmation, remind them to confirm the outline first
|
||||
- Content generation should ONLY happen when user explicitly clicks "Generate all sections" or "Generate [specific section]"
|
||||
- When user has generated content and wants to rewrite, use rewriteBlog() to collect feedback and rewriteBlog() to process
|
||||
- For rewrite requests, collect detailed feedback about what they want to change, tone, audience, and focus
|
||||
- After content generation, guide users to review and confirm their content before moving to SEO stage
|
||||
- When user says "I have reviewed and confirmed my blog content is ready for the next stage" or clicks "Next: Confirm Blog Content", IMMEDIATELY call confirmBlogContent() - DO NOT ask for additional confirmation
|
||||
- CRITICAL: If user explicitly confirms blog content, do NOT ask "are you sure?" or "please confirm" - the confirmation is already given
|
||||
- Only after content confirmation, show SEO analysis and publishing suggestions
|
||||
- When user asks for SEO analysis before content confirmation, remind them to confirm the content first
|
||||
- For SEO analysis, ALWAYS use analyzeSEO() - this is the ONLY SEO analysis tool available and provides comprehensive insights with visual interface
|
||||
- IMPORTANT: There is NO "basic" or "simple" SEO analysis - only the comprehensive one. Do NOT mention multiple SEO analysis options
|
||||
|
||||
ENGAGEMENT TACTICS:
|
||||
- DO NOT ask for clarification - take action immediately with the information provided
|
||||
- Always call the appropriate tool instead of just talking about what you could do
|
||||
- Be aware of the current state and reference research results when relevant
|
||||
- Guide users through the process: Research → Outline → Outline Review & Confirmation → Content → Content Review & Confirmation → SEO → Publish
|
||||
- Use encouraging language and highlight progress made
|
||||
- If user seems lost, remind them of the current stage and suggest the next step
|
||||
- When research is complete, emphasize the value of the data found and guide to outline creation
|
||||
- When outline is generated, emphasize the importance of reviewing and confirming before content generation
|
||||
- Encourage users to make small manual edits to the outline UI before using AI for major changes
|
||||
`;
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WriterCopilotSidebar;
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useRef } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
type ConfirmCb = () => string | Promise<string>;
|
||||
type AnalyzeCb = () => string | Promise<string>;
|
||||
type OpenMetadataCb = () => void;
|
||||
|
||||
interface UseBlogWriterCopilotActionsParams {
|
||||
isSEOAnalysisModalOpen: boolean;
|
||||
lastSEOModalOpenRef: React.MutableRefObject<number>;
|
||||
runSEOAnalysisDirect: AnalyzeCb;
|
||||
confirmBlogContent: ConfirmCb;
|
||||
sections: Record<string, string>;
|
||||
research: any;
|
||||
openSEOMetadata: OpenMetadataCb;
|
||||
}
|
||||
|
||||
// Consolidates all Copilot actions used by BlogWriter
|
||||
export function useBlogWriterCopilotActions({
|
||||
isSEOAnalysisModalOpen,
|
||||
lastSEOModalOpenRef,
|
||||
runSEOAnalysisDirect,
|
||||
confirmBlogContent,
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata,
|
||||
}: UseBlogWriterCopilotActionsParams) {
|
||||
// Maintain the same any-cast pattern for parity with component
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// confirmBlogContent
|
||||
useCopilotActionTyped({
|
||||
name: 'confirmBlogContent',
|
||||
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
const msg = await confirmBlogContent();
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
// analyzeSEO
|
||||
useCopilotActionTyped({
|
||||
name: 'analyzeSEO',
|
||||
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
debug.log('[BlogWriter] SEO analysis action', {
|
||||
modalOpen: isSEOAnalysisModalOpen,
|
||||
hasSections: !!sections && Object.keys(sections).length > 0,
|
||||
hasResearch: !!research && !!(research as any)?.keyword_analysis,
|
||||
});
|
||||
const now = Date.now();
|
||||
if (isSEOAnalysisModalOpen || now - lastSEOModalOpenRef.current < 750) {
|
||||
return 'SEO analysis is already open.';
|
||||
}
|
||||
const msg = await runSEOAnalysisDirect();
|
||||
return msg;
|
||||
},
|
||||
});
|
||||
|
||||
// generateSEOMetadata
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSEOMetadata',
|
||||
description: 'Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data',
|
||||
parameters: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
description: 'Optional blog title to use for metadata generation',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
|
||||
}
|
||||
if (!research || !research.keyword_analysis) {
|
||||
return 'Please complete research first to get keyword data for SEO metadata generation. Use the research features to gather keyword insights.';
|
||||
}
|
||||
openSEOMetadata();
|
||||
return 'Opening SEO metadata generator! This will create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default useBlogWriterCopilotActions;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||
import { debug } from '../../utils/debug';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
@@ -17,36 +18,27 @@ export const ContinuityBadge: React.FC<Props> = ({ sectionId, refreshToken, disa
|
||||
|
||||
// If we have flow analysis results, use them instead of API call
|
||||
if (flowAnalysisResults && flowAnalysisResults.sections) {
|
||||
console.log('🔍 [ContinuityBadge] Flow analysis results available:', flowAnalysisResults);
|
||||
console.log('🔍 [ContinuityBadge] Looking for section ID:', sectionId);
|
||||
console.log('🔍 [ContinuityBadge] Available section IDs:', flowAnalysisResults.sections.map((s: any) => s.section_id));
|
||||
|
||||
const sectionAnalysis = flowAnalysisResults.sections.find((s: any) => s.section_id === sectionId);
|
||||
if (sectionAnalysis) {
|
||||
console.log('🔍 [ContinuityBadge] Found section analysis:', sectionAnalysis);
|
||||
if (mounted) {
|
||||
setMetrics({
|
||||
flow: sectionAnalysis.flow_score, // Already in decimal format (0.0-1.0)
|
||||
flow: sectionAnalysis.flow_score,
|
||||
consistency: sectionAnalysis.consistency_score,
|
||||
progression: sectionAnalysis.progression_score
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
console.log('🔍 [ContinuityBadge] No matching section found for ID:', sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to API call if no flow analysis results
|
||||
console.log('🔍 [ContinuityBadge] Fetching continuity for section:', sectionId);
|
||||
debug.log('[ContinuityBadge] fetching', { sectionId });
|
||||
blogWriterApi.getContinuity(sectionId)
|
||||
.then(res => {
|
||||
console.log('🔍 [ContinuityBadge] Received continuity data:', res);
|
||||
if (mounted) setMetrics(res.continuity_metrics || null);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('🔍 [ContinuityBadge] Error fetching continuity:', error);
|
||||
/* ignore */
|
||||
debug.error('[ContinuityBadge] fetch error', error);
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [sectionId, refreshToken, flowAnalysisResults]);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
||||
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
|
||||
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
|
||||
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
|
||||
|
||||
interface Props {
|
||||
outline: BlogOutlineSection[];
|
||||
@@ -24,7 +25,10 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [hoveredSection, setHoveredSection] = useState<string | null>(null);
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
const [imageModalState, setImageModalState] = useState<{ open: boolean; sectionId?: string }>(() => ({ open: false }));
|
||||
const [sectionImages, setSectionImages] = useState<Record<string, string>>({});
|
||||
const [newSectionData, setNewSectionData] = useState({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
@@ -94,6 +98,31 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{imageModalState.open && (
|
||||
<ImageGeneratorModal
|
||||
isOpen={imageModalState.open}
|
||||
onClose={() => setImageModalState({ open: false })}
|
||||
defaultPrompt={(() => {
|
||||
const sec = outline.find(s => s.id === imageModalState.sectionId);
|
||||
return sec?.heading || '';
|
||||
})()}
|
||||
context={(() => {
|
||||
const sec = outline.find(s => s.id === imageModalState.sectionId);
|
||||
return {
|
||||
title: sec?.heading,
|
||||
section: sec,
|
||||
outline,
|
||||
research,
|
||||
sectionId: imageModalState.sectionId
|
||||
};
|
||||
})()}
|
||||
onImageGenerated={(imageBase64, sectionId) => {
|
||||
if (sectionId) {
|
||||
setSectionImages(prev => ({ ...prev, [sectionId]: imageBase64 }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
@@ -275,12 +304,15 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
{/* Section Header */}
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: expandedSections.has(section.id) ? '#f8f9fa' : 'white',
|
||||
backgroundColor: expandedSections.has(section.id) || hoveredSection === section.id ? '#f8f9fa' : 'white',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
justifyContent: 'space-between',
|
||||
transition: 'background-color 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={() => setHoveredSection(section.id)}
|
||||
onMouseLeave={() => setHoveredSection(null)}
|
||||
onClick={() => toggleExpanded(section.id)}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<div style={{
|
||||
@@ -375,6 +407,24 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageModalState({ open: true, sectionId: section.id });
|
||||
}}
|
||||
title="Generate Image"
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#fff'
|
||||
}}
|
||||
>
|
||||
🖼️ Generate Image
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@@ -448,7 +498,7 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
</div>
|
||||
|
||||
{/* Expanded Section Content */}
|
||||
{expandedSections.has(section.id) && (
|
||||
{(expandedSections.has(section.id) || hoveredSection === section.id) && (
|
||||
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
|
||||
{/* Subheadings */}
|
||||
{section.subheadings && section.subheadings.length > 0 && (
|
||||
@@ -533,6 +583,53 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated Image Display */}
|
||||
{sectionImages[section.id] && (
|
||||
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||
🖼️ Generated Image
|
||||
</h4>
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
maxWidth: '600px',
|
||||
backgroundColor: 'white'
|
||||
}}>
|
||||
<img
|
||||
src={`data:image/png;base64,${sectionImages[section.id]}`}
|
||||
alt={`Generated image for ${section.heading}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setImageModalState({ open: true, sectionId: section.id });
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
Generate Image for this section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,269 +12,11 @@ interface KeywordInputFormProps {
|
||||
onTaskStart?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
// Separate component to manage form state
|
||||
const ResearchForm: React.FC<{
|
||||
prompt?: string;
|
||||
onSubmit: (data: { keywords: string; blogLength: string }) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ prompt, onSubmit, onCancel }) => {
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [blogLength, setBlogLength] = useState('1000');
|
||||
const hasValidInput = keywords.trim().length > 0;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (hasValidInput) {
|
||||
onSubmit({ keywords: keywords.trim(), blogLength });
|
||||
} else {
|
||||
window.alert('Please enter keywords or a topic to start research.');
|
||||
}
|
||||
};
|
||||
|
||||
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' }}>
|
||||
🔍 Let's Research Your Blog Topic
|
||||
</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
{prompt || 'Please provide the keywords or topic you want to research for your blog:'}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Keywords or Topic *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '14px', fontWeight: '500' }}>
|
||||
Blog Length (words)
|
||||
</label>
|
||||
<select
|
||||
value={blogLength}
|
||||
onChange={(e) => setBlogLength(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #1976d2',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
backgroundColor: 'white',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<option value="500">500 words (Short blog)</option>
|
||||
<option value="1000">1000 words (Medium blog)</option>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000+ words (Comprehensive guide)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!hasValidInput}
|
||||
style={{
|
||||
backgroundColor: hasValidInput ? '#1976d2' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: hasValidInput ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
🚀 Start Research {hasValidInput ? '(Enabled)' : '(Disabled)'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 20px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// Keyword input action with Human-in-the-Loop
|
||||
useCopilotActionTyped({
|
||||
name: 'getResearchKeywords',
|
||||
description: 'Get keywords from user for blog research',
|
||||
parameters: [
|
||||
{ name: 'prompt', type: 'string', description: 'Prompt to show user', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ respond, args, status }: { respond?: (value: string) => void; args: { prompt?: string }; status: string }) => {
|
||||
if (status === 'complete') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f8ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #1976d2'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#1976d2', fontWeight: '500' }}>
|
||||
✅ Research keywords received! Starting research...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResearchForm
|
||||
prompt={args.prompt}
|
||||
onSubmit={(formData) => {
|
||||
onKeywordsReceived?.(formData);
|
||||
respond?.(JSON.stringify(formData));
|
||||
}}
|
||||
onCancel={() => respond?.('CANCEL')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Research action that actually performs the research
|
||||
useCopilotActionTyped({
|
||||
name: 'performResearch',
|
||||
description: 'Perform research with collected keywords and blog length',
|
||||
parameters: [
|
||||
{ name: 'formData', type: 'string', description: 'JSON string with keywords and blogLength', required: true }
|
||||
],
|
||||
handler: async ({ formData }: { formData: string }) => {
|
||||
try {
|
||||
const data = JSON.parse(formData);
|
||||
const { keywords, blogLength } = data;
|
||||
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map((k: string) => k.trim())
|
||||
: [keywords.trim()]; // Preserve single phrases as-is
|
||||
|
||||
// Check frontend cache first
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
|
||||
if (cachedResult) {
|
||||
console.log('Frontend cache hit - returning cached result instantly');
|
||||
onResearchComplete?.(cachedResult);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
|
||||
cached: true
|
||||
};
|
||||
}
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: 'General',
|
||||
target_audience: 'General',
|
||||
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,
|
||||
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${error}. Please try again with different keywords.`
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
margin: '8px 0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #1976d2',
|
||||
borderTop: '2px solid transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite'
|
||||
}} />
|
||||
<h4 style={{ margin: 0, color: '#1976d2' }}>🔍 Researching Your Topic</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.5' }}>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Connecting to Google Search grounding...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Analyzing keywords and search intent...</p>
|
||||
<p style={{ margin: '0 0 8px 0' }}>• Gathering relevant sources and statistics...</p>
|
||||
<p style={{ margin: '0' }}>• Generating content angles and search queries...</p>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
// This component now only provides polling functionality
|
||||
// The keyword input form is handled by ResearchAction component
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -294,4 +36,4 @@ export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsRe
|
||||
);
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
export default KeywordInputForm;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
|
||||
@@ -11,18 +11,38 @@ interface OutlineGeneratorProps {
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
research,
|
||||
onTaskStart,
|
||||
onPollingStart,
|
||||
onModalShow
|
||||
}) => {
|
||||
}, ref) => {
|
||||
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
||||
useImperativeHandle(ref, () => ({
|
||||
generateNow: async () => {
|
||||
if (!research) {
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
try {
|
||||
onModalShow?.();
|
||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||
onTaskStart(task_id);
|
||||
onPollingStart(task_id);
|
||||
return { success: true, task_id };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, message: errorMessage };
|
||||
}
|
||||
}
|
||||
}));
|
||||
useCopilotActionTyped({
|
||||
name: 'generateOutline',
|
||||
description: 'Generate outline from research results using AI analysis',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
if (!research) return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
if (!research) {
|
||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Show progress modal immediately when user clicks "Create outline"
|
||||
@@ -64,7 +84,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
}
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
console.log('generateOutline render called with status:', status);
|
||||
if (status === 'inProgress' || status === 'executing') {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -105,6 +124,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
};
|
||||
});
|
||||
|
||||
export default OutlineGenerator;
|
||||
|
||||
89
frontend/src/components/BlogWriter/PhaseNavigation.tsx
Normal file
89
frontend/src/components/BlogWriter/PhaseNavigation.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface Phase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
current: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
interface PhaseNavigationProps {
|
||||
phases: Phase[];
|
||||
onPhaseClick: (phaseId: string) => void;
|
||||
currentPhase: string;
|
||||
}
|
||||
|
||||
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
phases,
|
||||
onPhaseClick,
|
||||
currentPhase
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{phases.map((phase) => {
|
||||
const isCurrent = phase.current;
|
||||
const isCompleted = phase.completed;
|
||||
const isDisabled = phase.disabled;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={phase.id}
|
||||
onClick={() => !isDisabled && onPhaseClick(phase.id)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
backgroundColor: isCurrent
|
||||
? '#1976d2'
|
||||
: isCompleted
|
||||
? '#4caf50'
|
||||
: isDisabled
|
||||
? '#f5f5f5'
|
||||
: '#e3f2fd',
|
||||
color: isCurrent
|
||||
? 'white'
|
||||
: isCompleted
|
||||
? 'white'
|
||||
: isDisabled
|
||||
? '#999'
|
||||
: '#1976d2',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
|
||||
transform: isCurrent ? 'translateY(-1px)' : 'none'
|
||||
}}
|
||||
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{phase.icon}
|
||||
</span>
|
||||
<span>{phase.name}</span>
|
||||
{isCompleted && !isCurrent && (
|
||||
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseNavigation;
|
||||
89
frontend/src/components/BlogWriter/PhaseNavigationTest.tsx
Normal file
89
frontend/src/components/BlogWriter/PhaseNavigationTest.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
import PhaseNavigation from './PhaseNavigation';
|
||||
import { Phase } from './PhaseNavigation';
|
||||
|
||||
// Test component to verify phase navigation functionality
|
||||
export const PhaseNavigationTest: React.FC = () => {
|
||||
const [currentPhase, setCurrentPhase] = useState<string>('research');
|
||||
|
||||
const testPhases: Phase[] = [
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Research',
|
||||
icon: '🔍',
|
||||
description: 'Research your topic and gather data',
|
||||
completed: true,
|
||||
current: currentPhase === 'research',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Outline',
|
||||
icon: '📝',
|
||||
description: 'Create and refine your blog outline',
|
||||
completed: true,
|
||||
current: currentPhase === 'outline',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
name: 'Content',
|
||||
icon: '✍️',
|
||||
description: 'Generate and edit your blog content',
|
||||
completed: false,
|
||||
current: currentPhase === 'content',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
name: 'SEO',
|
||||
icon: '📈',
|
||||
description: 'Optimize for search engines',
|
||||
completed: false,
|
||||
current: currentPhase === 'seo',
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
name: 'Publish',
|
||||
icon: '🚀',
|
||||
description: 'Publish your blog post',
|
||||
completed: false,
|
||||
current: currentPhase === 'publish',
|
||||
disabled: true
|
||||
}
|
||||
];
|
||||
|
||||
const handlePhaseClick = (phaseId: string) => {
|
||||
setCurrentPhase(phaseId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h2>Phase Navigation Test</h2>
|
||||
<p>Current Phase: <strong>{currentPhase}</strong></p>
|
||||
|
||||
<PhaseNavigation
|
||||
phases={testPhases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||
<h3>Phase Status:</h3>
|
||||
<ul>
|
||||
{testPhases.map(phase => (
|
||||
<li key={phase.id}>
|
||||
<strong>{phase.name}</strong>:
|
||||
{phase.completed ? ' ✅ Completed' : ' ⏳ Pending'} |
|
||||
{phase.current ? ' 🎯 Current' : ''} |
|
||||
{phase.disabled ? ' 🚫 Disabled' : ' ✅ Enabled'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseNavigationTest;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
@@ -15,13 +15,18 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
|
||||
const [forceUpdate, setForceUpdate] = useState<number>(0);
|
||||
|
||||
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
|
||||
const keywordsRef = useRef<HTMLInputElement | null>(null);
|
||||
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
setCurrentMessage(message);
|
||||
setForceUpdate(prev => prev + 1); // Force re-render
|
||||
},
|
||||
onComplete: (result) => {
|
||||
// Cache the result for future use
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
@@ -35,84 +40,170 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'showResearchForm',
|
||||
description: 'Show keyword input form for blog research',
|
||||
parameters: [],
|
||||
handler: async () => ({
|
||||
success: true,
|
||||
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
|
||||
showForm: true
|
||||
}),
|
||||
render: ({ status }: any) => {
|
||||
const _ = forceUpdate;
|
||||
|
||||
if (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
|
||||
const latestMessage = polling.progressMessages[polling.progressMessages.length - 1];
|
||||
return (
|
||||
<div style={{ padding: '16px', backgroundColor: '#e8f5e8', borderRadius: '8px', border: '1px solid #4caf50', margin: '8px 0' }}>
|
||||
<p style={{ margin: 0, color: '#4caf50', fontWeight: '500' }}>✅ Research completed successfully!</p>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{latestMessage?.message || 'Research data is now available for your blog.'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (polling.currentStatus === 'in_progress' || polling.currentStatus === 'running') {
|
||||
return (
|
||||
<div style={{ padding: '16px', backgroundColor: '#fff3e0', borderRadius: '8px', border: '1px solid #ff9800', margin: '8px 0' }}>
|
||||
<p style={{ margin: 0, color: '#ff9800', fontWeight: '500' }}>🔄 Research in progress...</p>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: '14px' }}>{currentMessage || 'Gathering research data...'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '12px', border: '1px solid #e0e0e0', margin: '8px 0' }}>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>🔍 Let's Research Your Blog Topic</h4>
|
||||
<p style={{ margin: '0 0 16px 0', color: '#666', fontSize: '14px' }}>
|
||||
What keywords and information would you like to use for your research? Please also specify the desired length of the blog post.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Keywords or Topic *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="research-keywords-input"
|
||||
placeholder="e.g., artificial intelligence, machine learning, AI trends"
|
||||
ref={keywordsRef}
|
||||
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500', color: '#333' }}>Blog Length (words)</label>
|
||||
<select
|
||||
id="research-blog-length-select"
|
||||
defaultValue="1000"
|
||||
ref={blogLengthRef}
|
||||
style={{ width: '100%', padding: '12px', border: '1px solid #ddd', borderRadius: '6px', fontSize: '14px', boxSizing: 'border-box' }}
|
||||
>
|
||||
<option value="500">500 words (Short blog)</option>
|
||||
<option value="1000">1000 words (Medium blog)</option>
|
||||
<option value="1500">1500 words (Long blog)</option>
|
||||
<option value="2000">2000 words (Comprehensive blog)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const keywords = (keywordsRef.current?.value || '').trim();
|
||||
const blogLength = blogLengthRef.current?.value || '1000';
|
||||
if (!keywords) return;
|
||||
try {
|
||||
const keywordList = keywords.includes(',') ? keywords.split(',').map(k => k.trim()).filter(Boolean) : [keywords];
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, 'General', 'General');
|
||||
if (cachedResult) {
|
||||
onResearchComplete?.(cachedResult);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
return;
|
||||
}
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: 'General',
|
||||
target_audience: 'General',
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
setShowProgressModal(true);
|
||||
polling.startPolling(task_id);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
}
|
||||
}}
|
||||
style={{ padding: '12px 24px', backgroundColor: '#1976d2', color: 'white', border: 'none', borderRadius: '6px', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
|
||||
>
|
||||
🚀 Start Research
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Additional action to catch the specific suggestion message
|
||||
useCopilotActionTyped({
|
||||
name: 'researchTopic',
|
||||
description: 'Research topic with keywords and persona context using Google Search grounding',
|
||||
parameters: [
|
||||
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: true },
|
||||
{ name: 'keywords', type: 'string', description: 'Comma-separated keywords or topic description', required: false },
|
||||
{ name: 'industry', type: 'string', description: 'Industry', required: false },
|
||||
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
|
||||
{ name: 'blogLength', type: 'string', description: 'Target blog length in words', required: false }
|
||||
],
|
||||
handler: async ({ keywords, industry, target_audience, blogLength }: { keywords: string; industry?: string; target_audience?: string; blogLength?: string }) => {
|
||||
handler: async ({ keywords = '', industry = 'General', target_audience = 'General', blogLength = '1000' }: any) => {
|
||||
try {
|
||||
// If keywords is a topic description, preserve as single phrase unless comma-separated
|
||||
const keywordList = keywords.includes(',')
|
||||
? keywords.split(',').map(k => k.trim())
|
||||
: [keywords.trim()]; // Preserve single phrases as-is
|
||||
|
||||
const industryValue = industry || 'General';
|
||||
const audienceValue = target_audience || 'General';
|
||||
|
||||
// Check frontend cache first
|
||||
const cachedResult = researchCache.getCachedResult(keywordList, industryValue, audienceValue);
|
||||
if (cachedResult) {
|
||||
console.log('Frontend cache hit - returning cached result instantly');
|
||||
onResearchComplete?.(cachedResult);
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Found cached research for "${keywords}"! Results loaded instantly.`,
|
||||
cached: true
|
||||
};
|
||||
const trimmed = keywords.trim();
|
||||
if (!trimmed) {
|
||||
return "Please provide keywords or a topic for research.";
|
||||
}
|
||||
|
||||
const keywordList = trimmed.includes(',')
|
||||
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
|
||||
: [trimmed];
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry: industryValue,
|
||||
target_audience: audienceValue,
|
||||
word_count_target: blogLength ? parseInt(blogLength) : 1000
|
||||
industry,
|
||||
target_audience,
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
|
||||
// Start async research
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
setShowProgressModal(true);
|
||||
polling.startPolling(task_id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
|
||||
task_id: task_id
|
||||
};
|
||||
return "Starting research with your provided keywords.";
|
||||
} catch (error) {
|
||||
console.error(`Research failed: ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `❌ Research failed: ${error}. The AI research system encountered an issue. Please try again with different keywords or contact support if the problem persists.`
|
||||
};
|
||||
console.error('Failed to start research:', error);
|
||||
return "Failed to start research. Please try again.";
|
||||
}
|
||||
},
|
||||
render: () => null
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal}
|
||||
title="Research in progress"
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => setShowProgressModal(false)}
|
||||
/>
|
||||
<>
|
||||
{showProgressModal && (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal}
|
||||
title={"Research in progress"}
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => setShowProgressModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useResearchPolling } from '../../hooks/usePolling';
|
||||
import ResearchProgressModal from './ResearchProgressModal';
|
||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { researchCache } from '../../services/researchCache';
|
||||
import { debug } from '../../utils/debug';
|
||||
|
||||
interface ResearchPollingHandlerProps {
|
||||
taskId: string | null;
|
||||
@@ -19,11 +20,11 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
console.log('ResearchPollingHandler - Progress message received:', message);
|
||||
debug.log('[ResearchPollingHandler] progress', { message });
|
||||
setCurrentMessage(message);
|
||||
},
|
||||
onComplete: (result) => {
|
||||
console.log('ResearchPollingHandler - Research completed:', result);
|
||||
debug.log('[ResearchPollingHandler] complete');
|
||||
|
||||
// Cache the result for future use
|
||||
if (result && result.keywords) {
|
||||
@@ -39,7 +40,7 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
setCurrentMessage('');
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
debug.error('[ResearchPollingHandler] error', error);
|
||||
onError?.(error);
|
||||
setCurrentMessage('');
|
||||
}
|
||||
@@ -61,14 +62,14 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
};
|
||||
}, [polling]);
|
||||
|
||||
console.log('ResearchPollingHandler render:', {
|
||||
taskId,
|
||||
isPolling: polling.isPolling,
|
||||
status: polling.currentStatus,
|
||||
progressMessages: polling.progressMessages?.length,
|
||||
currentMessage,
|
||||
error: polling.error
|
||||
});
|
||||
// Only log on meaningful changes
|
||||
useEffect(() => {
|
||||
debug.log('[ResearchPollingHandler] state', {
|
||||
isPolling: polling.isPolling,
|
||||
status: polling.currentStatus,
|
||||
progressCount: polling.progressMessages?.length || 0
|
||||
});
|
||||
}, [polling.isPolling, polling.currentStatus, polling.progressMessages?.length]);
|
||||
|
||||
// Render the unified research progress modal when a task is present
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Keyword Analysis Component
|
||||
*
|
||||
*
|
||||
* Displays comprehensive keyword analysis including keyword types, densities,
|
||||
* missing keywords, over-optimization, and distribution analysis.
|
||||
*/
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
GpsFixed,
|
||||
Search,
|
||||
Warning
|
||||
@@ -36,86 +36,140 @@ interface KeywordAnalysisProps {
|
||||
};
|
||||
}
|
||||
|
||||
const baseCardSx = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const subCard = (color: string) => ({
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${color}`,
|
||||
background: `linear-gradient(145deg, ${color}14, ${color}1f)`
|
||||
});
|
||||
|
||||
export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalysis }) => {
|
||||
const keywordData = detailedAnalysis?.keyword_analysis;
|
||||
|
||||
const renderDensityRow = (keyword: string, density: number) => {
|
||||
const status = density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal';
|
||||
const chipColor = density > 3 ? 'error' : density < 1 ? 'warning' : 'success';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={keyword}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: '#334155' }}>
|
||||
{keyword}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{status}
|
||||
</Typography>
|
||||
<Chip label={`${density.toFixed(1)}%`} color={chipColor} size="small" sx={{ fontWeight: 600 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GpsFixed sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Keyword Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Keyword Types Overview */}
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={baseCardSx}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
|
||||
Keyword Types Found
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
||||
<Box sx={subCard('rgba(34,197,94,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#16a34a', mb: 1 }}>
|
||||
Primary Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.primary_keywords?.length || 0} found
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.primary_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.primary_keywords?.slice(0, 3).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.primary_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} size="small" sx={{ fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
||||
<Box sx={subCard('rgba(59,130,246,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#2563eb', mb: 1 }}>
|
||||
Long-tail Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.length || 0} found
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.long_tail_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.slice(0, 2).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" variant="outlined" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.long_tail_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ fontWeight: 600, borderColor: '#93c5fd', color: '#1d4ed8' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
||||
<Box sx={subCard('rgba(168,85,247,0.5)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#9333ea', mb: 1 }}>
|
||||
Semantic Keywords
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.length || 0} found
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{keywordData?.semantic_keywords?.length || 0} found
|
||||
</Typography>
|
||||
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.slice(0, 2).map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} size="small" variant="outlined" color="secondary" sx={{ mr: 0.5, mb: 0.5 }} />
|
||||
))}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{keywordData?.semantic_keywords?.slice(0, 3).map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" color="secondary" size="small" sx={{ fontWeight: 600, borderColor: '#d8b4fe' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Keyword Densities */}
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
Keyword Densities
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Keyword Density Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Shows how frequently each keyword appears in your content as a percentage of total words.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 1-3% for primary keywords
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Too Low (<1%):</strong> Keyword may not be prominent enough
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Too High (>3%):</strong> Risk of keyword stuffing
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -123,108 +177,96 @@ export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalys
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||
<Search />
|
||||
<Search fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{detailedAnalysis?.keyword_analysis?.keyword_density && Object.keys(detailedAnalysis.keyword_analysis.keyword_density).length > 0 ? (
|
||||
Object.entries(detailedAnalysis.keyword_analysis.keyword_density).map(([keyword, density]) => (
|
||||
<Box key={keyword} sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>{keyword}</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{density > 3 ? 'Over-optimized' : density < 1 ? 'Under-optimized' : 'Optimal'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={`${density.toFixed(1)}%`}
|
||||
color={density > 3 ? 'error' : density < 1 ? 'warning' : 'success'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2 }}>
|
||||
{keywordData?.keyword_density && Object.keys(keywordData.keyword_density).length > 0 ? (
|
||||
Object.entries(keywordData.keyword_density).map(([keyword, density]) => renderDensityRow(keyword, density))
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
No keyword density data available. Make sure your research data includes target keywords.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
|
||||
{/* Missing Keywords */}
|
||||
{detailedAnalysis?.keyword_analysis?.missing_keywords && detailedAnalysis.keyword_analysis.missing_keywords.length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
{keywordData?.missing_keywords && keywordData.missing_keywords.length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'error.main' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#dc2626' }}>
|
||||
Missing Keywords
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Keywords from your research that are not found in the content. Consider adding these to improve SEO."
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'error.main' }}>
|
||||
<Warning />
|
||||
<Tooltip title="Keywords from your research that are not found in the content. Consider adding these to improve SEO." arrow>
|
||||
<IconButton size="small" sx={{ color: '#dc2626' }}>
|
||||
<Warning fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{detailedAnalysis.keyword_analysis.missing_keywords.map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} color="error" variant="outlined" />
|
||||
{keywordData.missing_keywords.map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fecaca', color: '#b91c1c', fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
|
||||
{/* Over-Optimized Keywords */}
|
||||
{detailedAnalysis?.keyword_analysis?.over_optimization && detailedAnalysis.keyword_analysis.over_optimization.length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
{keywordData?.over_optimization && keywordData.over_optimization.length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: 'warning.main' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#d97706' }}>
|
||||
Over-Optimized Keywords
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="Keywords that appear too frequently (over 3% density). Consider reducing their usage to avoid keyword stuffing penalties."
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'warning.main' }}>
|
||||
<Warning />
|
||||
<Tooltip title="Keywords that appear too frequently (over 3% density). Consider reducing their usage." arrow>
|
||||
<IconButton size="small" sx={{ color: '#d97706' }}>
|
||||
<Warning fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||
{detailedAnalysis.keyword_analysis.over_optimization.map((keyword: string) => (
|
||||
<Chip key={keyword} label={keyword} color="warning" variant="outlined" />
|
||||
{keywordData.over_optimization.map((keyword) => (
|
||||
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fcd34d', color: '#b45309', fontWeight: 600 }} />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Keyword Distribution Analysis */}
|
||||
{detailedAnalysis?.keyword_analysis?.keyword_distribution && Object.keys(detailedAnalysis.keyword_analysis.keyword_distribution).length > 0 && (
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{keywordData?.keyword_distribution && Object.keys(keywordData.keyword_distribution).length > 0 && (
|
||||
<Paper sx={baseCardSx}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
|
||||
Keyword Distribution Analysis
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(detailedAnalysis.keyword_analysis.keyword_distribution).map(([keyword, data]: [string, any]) => (
|
||||
<Box key={keyword} sx={{ p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
"{keyword}"
|
||||
{Object.entries(keywordData.keyword_distribution).map(([keyword, data]: [string, any]) => (
|
||||
<Box
|
||||
key={keyword}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||
“{keyword}”
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
Density: {data.density?.toFixed(1)}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
In Headings: {data.in_headings ? 'Yes' : 'No'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
First Occurrence: Character {data.first_occurrence || 'Not found'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
@@ -75,9 +75,22 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
// Consistent text input styling for better contrast
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#dadce0'
|
||||
}
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
|
||||
<SearchIcon sx={{ color: 'primary.main' }} />
|
||||
Core SEO Metadata
|
||||
</Typography>
|
||||
@@ -85,10 +98,10 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
<Grid container spacing={3}>
|
||||
{/* SEO Title */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
SEO Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -107,6 +120,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
value={metadata.seo_title || ''}
|
||||
onChange={handleTextFieldChange('seo_title')}
|
||||
placeholder="Enter SEO-optimized title (50-60 characters)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -120,18 +134,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Include your primary keyword and make it compelling for clicks
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Include your primary keyword and keep between 50–60 characters
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Meta Description */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Meta Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -150,6 +164,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
value={metadata.meta_description || ''}
|
||||
onChange={handleTextFieldChange('meta_description')}
|
||||
placeholder="Enter compelling meta description (150-160 characters)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -163,18 +178,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Include a call-to-action and your primary keyword
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Aim for 150–160 characters with a clear value proposition
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* URL Slug */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<LinkIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<LinkIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
URL Slug
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -192,16 +207,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
onChange={handleTextFieldChange('url_slug')}
|
||||
placeholder="seo-friendly-url-slug"
|
||||
helperText="Use lowercase letters, numbers, and hyphens only"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Focus Keyword */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TrendingUpIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TrendingUpIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Focus Keyword
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -219,16 +236,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
onChange={handleTextFieldChange('focus_keyword')}
|
||||
placeholder="primary-keyword"
|
||||
helperText="Your main SEO keyword for this post"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Blog Tags */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TagIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Blog Tags
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -241,12 +260,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Tags</InputLabel>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Tags</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.blog_tags || []}
|
||||
onChange={handleTagsChange('blog_tags')}
|
||||
input={<OutlinedInput label="Tags" />}
|
||||
input={<OutlinedInput label="Tags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
@@ -262,18 +281,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Add relevant tags for better categorization and discoverability
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Add 3–6 relevant tags for better categorization and discoverability
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Blog Categories */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CategoryIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<CategoryIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Blog Categories
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -286,12 +305,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Categories</InputLabel>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Categories</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.blog_categories || []}
|
||||
onChange={handleTagsChange('blog_categories')}
|
||||
input={<OutlinedInput label="Categories" />}
|
||||
input={<OutlinedInput label="Categories" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
@@ -307,18 +326,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Select 2-3 primary categories for your content
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Select 1–3 primary categories for your content
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Social Hashtags */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TagIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Social Hashtags
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -331,12 +350,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Hashtags</InputLabel>
|
||||
<InputLabel sx={{ color: '#5f6368' }}>Hashtags</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={metadata.social_hashtags || []}
|
||||
onChange={handleTagsChange('social_hashtags')}
|
||||
input={<OutlinedInput label="Hashtags" />}
|
||||
input={<OutlinedInput label="Hashtags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value: string) => (
|
||||
@@ -352,18 +371,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Include # symbol for social media platforms
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||
Include # symbol (e.g., #multimodalAI). 3–5 hashtags recommended.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Reading Time */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ScheduleIcon sx={{ fontSize: 20 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<ScheduleIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||
Reading Time
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -385,6 +404,8 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
||||
endAdornment: <InputAdornment position="end">minutes</InputAdornment>
|
||||
}}
|
||||
helperText="Estimated reading time for your content"
|
||||
sx={textInputSx}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
/>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
@@ -12,28 +12,35 @@ import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Alert
|
||||
Tabs,
|
||||
Tab,
|
||||
Tooltip,
|
||||
IconButton
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search as SearchIcon,
|
||||
Code as CodeIcon,
|
||||
Facebook as FacebookIcon,
|
||||
Twitter as TwitterIcon,
|
||||
Google as GoogleIcon
|
||||
Google as GoogleIcon,
|
||||
Info as InfoIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface PreviewCardProps {
|
||||
metadata: any;
|
||||
blogTitle: string;
|
||||
previewTabValue: string;
|
||||
onPreviewTabChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PreviewCard: React.FC<PreviewCardProps> = ({
|
||||
metadata,
|
||||
blogTitle
|
||||
blogTitle,
|
||||
previewTabValue,
|
||||
onPreviewTabChange
|
||||
}) => {
|
||||
const getCurrentDate = () => {
|
||||
return new Date().toLocaleDateString('en-US', {
|
||||
@@ -45,320 +52,491 @@ export const PreviewCard: React.FC<PreviewCardProps> = ({
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{/* Title with Tooltip */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<SearchIcon sx={{ color: 'primary.main' }} />
|
||||
Live Preview
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Live Preview
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title="This is how your blog post will appear in search results and social media platforms"
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'text.secondary' }}>
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Google Search Results Preview */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GoogleIcon sx={{ color: '#4285F4' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Google Search Results
|
||||
{/* Platform Sub-Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Tabs
|
||||
value={previewTabValue}
|
||||
onChange={(e, newValue) => onPreviewTabChange(newValue)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
minHeight: 48
|
||||
},
|
||||
'& .Mui-selected': {
|
||||
fontWeight: 600
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
icon={<GoogleIcon />}
|
||||
iconPosition="start"
|
||||
label="Google Search Results"
|
||||
value="google"
|
||||
/>
|
||||
<Tab
|
||||
icon={<FacebookIcon />}
|
||||
iconPosition="start"
|
||||
label="Facebook Preview"
|
||||
value="facebook"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TwitterIcon />}
|
||||
iconPosition="start"
|
||||
label="Twitter Preview"
|
||||
value="twitter"
|
||||
/>
|
||||
<Tab
|
||||
icon={<CodeIcon />}
|
||||
iconPosition="start"
|
||||
label="Rich Snippets Preview"
|
||||
value="richsnippets"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Google Search Results Preview */}
|
||||
{previewTabValue === 'google' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<GoogleIcon sx={{ color: '#4285F4', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Google Search Results
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Google SERP Preview - Light Theme (matches actual Google) */}
|
||||
<Card
|
||||
sx={{
|
||||
background: '#ffffff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 2.5 }}>
|
||||
{/* URL - Google Blue */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#202124',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.3,
|
||||
mb: 0.5,
|
||||
display: 'block',
|
||||
fontFamily: 'arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
|
||||
</Typography>
|
||||
<Chip label="SERP Preview" size="small" color="primary" />
|
||||
</Box>
|
||||
|
||||
{/* Title - Google Blue, hover underline */}
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#1a0dab',
|
||||
fontWeight: 400,
|
||||
fontSize: '20px',
|
||||
lineHeight: 1.3,
|
||||
mb: 0.5,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'arial, sans-serif',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
}}
|
||||
>
|
||||
{metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description - Google Gray */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#4d5156',
|
||||
lineHeight: 1.58,
|
||||
fontSize: '14px',
|
||||
fontFamily: 'arial, sans-serif',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
|
||||
</Typography>
|
||||
|
||||
{/* Additional Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mt: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#70757a',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{getCurrentDate()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
|
||||
• {metadata.reading_time || 5} min read
|
||||
</Typography>
|
||||
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '14px' }}>
|
||||
• {metadata.blog_tags.slice(0, 2).join(', ')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
{/* Facebook Preview */}
|
||||
{previewTabValue === 'facebook' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1c1e21' }}>
|
||||
Facebook Preview
|
||||
</Typography>
|
||||
<Chip label="Open Graph" size="small" sx={{ bgcolor: '#e7f3ff', color: '#1877F2' }} />
|
||||
</Box>
|
||||
|
||||
{/* Facebook Card Preview */}
|
||||
<Card
|
||||
sx={{
|
||||
border: '1px solid #dadde1',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 500,
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 262,
|
||||
bgcolor: '#f2f3f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #dadde1'
|
||||
}}>
|
||||
{metadata.open_graph?.image ? (
|
||||
<Typography variant="caption" sx={{ color: '#65676b' }}>
|
||||
Image loaded
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#65676b' }}>
|
||||
No image set
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
|
||||
{/* URL */}
|
||||
<Typography variant="caption" sx={{ color: '#1a0dab', mb: 1, display: 'block' }}>
|
||||
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#65676b',
|
||||
fontSize: '12px',
|
||||
mb: 0.75,
|
||||
display: 'block',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="h6"
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
color: '#1a0dab',
|
||||
fontWeight: 400,
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: 1.3,
|
||||
mb: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { textDecoration: 'underline' }
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
lineHeight: 1.33,
|
||||
fontSize: '17px',
|
||||
color: '#050505',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.seo_title || blogTitle}
|
||||
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" sx={{ color: '#4d5156', lineHeight: 1.4, mb: 1 }}>
|
||||
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#65676b',
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Twitter Preview */}
|
||||
{previewTabValue === 'twitter' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#0f1419' }}>
|
||||
Twitter Preview
|
||||
</Typography>
|
||||
<Chip label="Twitter Card" size="small" sx={{ bgcolor: '#e1f5fe', color: '#1DA1F2' }} />
|
||||
</Box>
|
||||
|
||||
{/* Twitter Card Preview */}
|
||||
<Card
|
||||
sx={{
|
||||
border: '1px solid #eff3f4',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 500,
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 262,
|
||||
bgcolor: '#f7f9fa',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #eff3f4'
|
||||
}}>
|
||||
{metadata.twitter_card?.image ? (
|
||||
<Typography variant="caption" sx={{ color: '#536471' }}>
|
||||
Image loaded
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ color: '#536471' }}>
|
||||
No image set
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2.5, bgcolor: '#ffffff' }}>
|
||||
{/* URL */}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
fontSize: '13px',
|
||||
mb: 0.75,
|
||||
display: 'block',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.canonical_url ? new URL(metadata.canonical_url).hostname : 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Additional Info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{getCurrentDate()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
•
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{metadata.reading_time || 5} min read
|
||||
</Typography>
|
||||
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
|
||||
<>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
•
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{metadata.blog_tags.slice(0, 2).join(', ')}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
This is how your blog post will appear in Google search results
|
||||
</Alert>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Social Media Previews */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Facebook Preview
|
||||
</Typography>
|
||||
<Chip label="Open Graph" size="small" color="primary" />
|
||||
</Box>
|
||||
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', maxWidth: 400 }}>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 200,
|
||||
bgcolor: '#f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{metadata.open_graph?.image ? 'Image loaded' : 'No image set'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2 }}>
|
||||
{/* URL */}
|
||||
<Typography variant="caption" sx={{ color: '#65676b', mb: 1, display: 'block' }}>
|
||||
{metadata.canonical_url || 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Title */}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1, lineHeight: 1.3 }}>
|
||||
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" sx={{ color: '#65676b', lineHeight: 1.4 }}>
|
||||
{metadata.open_graph?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Twitter Preview
|
||||
</Typography>
|
||||
<Chip label="Twitter Card" size="small" color="info" />
|
||||
</Box>
|
||||
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', maxWidth: 400 }}>
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
{/* Image placeholder */}
|
||||
<Box sx={{
|
||||
height: 200,
|
||||
bgcolor: '#f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{metadata.twitter_card?.image ? 'Image loaded' : 'No image set'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2 }}>
|
||||
{/* URL */}
|
||||
<Typography variant="caption" sx={{ color: '#536471', mb: 1, display: 'block' }}>
|
||||
{metadata.canonical_url || 'yourwebsite.com'}
|
||||
</Typography>
|
||||
|
||||
{/* Title */}
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1, lineHeight: 1.3 }}>
|
||||
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" sx={{ color: '#536471', lineHeight: 1.4 }}>
|
||||
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
|
||||
{/* Twitter handle */}
|
||||
{metadata.twitter_card?.site && (
|
||||
<Typography variant="caption" sx={{ color: '#536471', mt: 1, display: 'block' }}>
|
||||
{metadata.twitter_card.site}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Rich Snippets Preview */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<CodeIcon sx={{ color: '#34A853' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Rich Snippets Preview
|
||||
</Typography>
|
||||
<Chip label="JSON-LD Schema" size="small" color="success" />
|
||||
</Box>
|
||||
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
{/* Article Schema Preview */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
<Chip label="Article" size="small" color="success" />
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" sx={{ color: '#4d5156', mb: 2 }}>
|
||||
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
color: '#0f1419',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{metadata.json_ld_schema?.author?.name && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
By {metadata.json_ld_schema.author.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.json_ld_schema?.datePublished && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.reading_time && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{metadata.reading_time} min read
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.json_ld_schema?.wordCount && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
||||
{metadata.json_ld_schema.wordCount} words
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
lineHeight: 1.33,
|
||||
fontSize: '15px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||
</Typography>
|
||||
|
||||
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="caption" sx={{ color: '#4d5156', display: 'block', mb: 1 }}>
|
||||
Keywords:
|
||||
{/* Twitter handle */}
|
||||
{metadata.twitter_card?.site && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: '#536471',
|
||||
mt: 1,
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||
}}
|
||||
>
|
||||
{metadata.twitter_card.site}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Rich Snippets Preview */}
|
||||
{previewTabValue === 'richsnippets' && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<CodeIcon sx={{ color: '#34A853', fontSize: 28 }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Rich Snippets Preview
|
||||
</Typography>
|
||||
<Chip label="JSON-LD Schema" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
|
||||
</Box>
|
||||
|
||||
{/* Rich Snippets Card */}
|
||||
<Card
|
||||
sx={{
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 2,
|
||||
boxShadow: 'none',
|
||||
maxWidth: 600
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
{/* Article Schema Preview */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
{metadata.json_ld_schema?.headline || metadata.seo_title || blogTitle}
|
||||
</Typography>
|
||||
<Chip label="Article" size="small" sx={{ bgcolor: '#e8f5e9', color: '#34A853' }} />
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: '#4d5156',
|
||||
mb: 2,
|
||||
lineHeight: 1.6,
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
{metadata.json_ld_schema?.description || metadata.meta_description || 'Article description...'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap', mb: 2 }}>
|
||||
{metadata.json_ld_schema?.author?.name && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
By {metadata.json_ld_schema.author.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
|
||||
<Chip key={index} label={keyword} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
Rich snippets help search engines understand your content and may display additional information in search results
|
||||
</Alert>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Metadata Summary */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon />
|
||||
Metadata Summary
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(76, 175, 80, 0.1)', borderRadius: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'success.main' }}>
|
||||
{metadata.optimization_score || 0}%
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Optimization Score
|
||||
|
||||
{metadata.json_ld_schema?.datePublished && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.reading_time && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{metadata.reading_time} min read
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{metadata.json_ld_schema?.wordCount && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||
{metadata.json_ld_schema.wordCount} words
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
|
||||
<Box sx={{ mt: 2, pt: 2, borderTop: '1px solid #e0e0e0' }}>
|
||||
<Typography variant="caption" sx={{ color: '#70757a', display: 'block', mb: 1, fontWeight: 500 }}>
|
||||
Keywords:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{metadata.json_ld_schema.keywords.slice(0, 5).map((keyword: string, index: number) => (
|
||||
<Chip
|
||||
key={index}
|
||||
label={keyword}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ borderColor: '#e0e0e0', color: '#4d5156' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(33, 150, 243, 0.1)', borderRadius: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'primary.main' }}>
|
||||
{metadata.reading_time || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Reading Time (min)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(156, 39, 176, 0.1)', borderRadius: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'secondary.main' }}>
|
||||
{metadata.blog_tags?.length || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Tags
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(255, 152, 0, 0.1)', borderRadius: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'warning.main' }}>
|
||||
{metadata.blog_categories?.length || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Categories
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -71,12 +71,25 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
// Consistent text input styling for better contrast
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#dadce0'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const openGraph = metadata.open_graph || {};
|
||||
const twitterCard = metadata.twitter_card || {};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 1, color: '#202124', fontWeight: 600 }}>
|
||||
<ShareIcon sx={{ color: 'primary.main' }} />
|
||||
Social Media Metadata
|
||||
</Typography>
|
||||
@@ -84,11 +97,11 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
<Grid container spacing={3}>
|
||||
{/* Open Graph Section */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
<LinkedInIcon sx={{ color: '#0077B5' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Open Graph Tags
|
||||
</Typography>
|
||||
<Chip label="Facebook & LinkedIn" size="small" color="primary" />
|
||||
@@ -97,7 +110,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -114,6 +127,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={openGraph.title || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'title')}
|
||||
placeholder="Open Graph title (60 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -131,7 +145,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -150,6 +164,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={openGraph.description || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'description')}
|
||||
placeholder="Open Graph description (160 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -167,7 +182,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG Image URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -184,6 +199,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={openGraph.image || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'image')}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@@ -196,7 +212,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
OG URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -213,6 +229,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={openGraph.url || ''}
|
||||
onChange={handleNestedFieldChange('open_graph', 'url')}
|
||||
placeholder="https://example.com/blog-post"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@@ -224,18 +241,18 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Open Graph tags are used by Facebook, LinkedIn, and other social platforms to display rich previews
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
|
||||
Open Graph tags are used by Facebook, LinkedIn, and others to display rich previews.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Twitter Card Section */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Card Tags
|
||||
</Typography>
|
||||
<Chip label="Twitter & X" size="small" color="info" />
|
||||
@@ -244,7 +261,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Title
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -261,6 +278,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={twitterCard.title || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'title')}
|
||||
placeholder="Twitter card title (70 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -278,7 +296,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Description
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -297,6 +315,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={twitterCard.description || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'description')}
|
||||
placeholder="Twitter card description (200 characters max)"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
@@ -314,7 +333,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Image URL
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -331,6 +350,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={twitterCard.image || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'image')}
|
||||
placeholder="https://example.com/twitter-image.jpg"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@@ -343,7 +363,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Site Handle
|
||||
</Typography>
|
||||
<Tooltip title="Copy to clipboard">
|
||||
@@ -360,6 +380,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
value={twitterCard.site || ''}
|
||||
onChange={handleNestedFieldChange('twitter_card', 'site')}
|
||||
placeholder="@yourwebsite"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
@@ -371,16 +392,16 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Twitter cards provide rich previews when your content is shared on Twitter/X
|
||||
</Alert>
|
||||
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
|
||||
Twitter cards provide rich previews when your content is shared on Twitter/X.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* Social Media Preview */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||
<ShareIcon />
|
||||
Social Media Preview
|
||||
</Typography>
|
||||
@@ -388,22 +409,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
<Grid container spacing={2}>
|
||||
{/* Facebook Preview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0' }}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Facebook Preview
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
|
||||
{openGraph.title || 'Your Blog Title'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
|
||||
{openGraph.url || 'yourwebsite.com'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
|
||||
{openGraph.description || 'Your meta description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -413,22 +434,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
||||
|
||||
{/* Twitter Preview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0' }}>
|
||||
<Card sx={{ border: '1px solid #e0e0e0', boxShadow: 'none', background: '#ffffff' }}>
|
||||
<CardContent sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||
Twitter Preview
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
|
||||
{twitterCard.title || 'Your Blog Title'}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', mb: 1, display: 'block' }}>
|
||||
<Typography variant="caption" sx={{ color: '#5f6368', mb: 1, display: 'block' }}>
|
||||
{twitterCard.site || '@yourwebsite'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem' }}>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.875rem', color: '#5f6368' }}>
|
||||
{twitterCard.description || 'Your Twitter description will appear here...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -56,6 +56,28 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
}) => {
|
||||
const [showRawJson, setShowRawJson] = useState(false);
|
||||
|
||||
// Helpers for counters and consistent input styling
|
||||
const getCharacterCountColor = (current: number, max: number) => {
|
||||
if (current > max) return 'error';
|
||||
if (current > max * 0.9) return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const getCharacterCountText = (current: number, max: number) => {
|
||||
if (current > max) return `${current}/${max} (Too long)`;
|
||||
if (current > max * 0.9) return `${current}/${max} (Near limit)`;
|
||||
return `${current}/${max}`;
|
||||
};
|
||||
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#202124'
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: '#5f6368'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const handleTextFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onMetadataEdit(field, event.target.value);
|
||||
};
|
||||
@@ -123,7 +145,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
<Grid container spacing={3}>
|
||||
{/* Article Information */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon />
|
||||
Article Schema
|
||||
@@ -149,6 +171,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={jsonLdSchema.headline || ''}
|
||||
onChange={handleSchemaFieldChange('headline')}
|
||||
placeholder="Article headline"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((jsonLdSchema.headline || '').length, 110)}
|
||||
>
|
||||
{getCharacterCountText((jsonLdSchema.headline || '').length, 110)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -173,6 +208,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={jsonLdSchema.description || ''}
|
||||
onChange={handleSchemaFieldChange('description')}
|
||||
placeholder="Article description"
|
||||
sx={textInputSx}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color={getCharacterCountColor((jsonLdSchema.description || '').length, 200)}
|
||||
>
|
||||
{getCharacterCountText((jsonLdSchema.description || '').length, 200)}
|
||||
</Typography>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -202,6 +250,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -228,6 +277,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">words</InputAdornment>
|
||||
}}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -236,7 +286,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
|
||||
{/* Author Information */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PersonIcon />
|
||||
Author Information
|
||||
@@ -262,6 +312,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={author.name || ''}
|
||||
onChange={handleAuthorFieldChange('name')}
|
||||
placeholder="Author Name"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -284,6 +335,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={author['@type'] || ''}
|
||||
onChange={handleAuthorFieldChange('@type')}
|
||||
placeholder="Person"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -292,7 +344,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
|
||||
{/* Publisher Information */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BusinessIcon />
|
||||
Publisher Information
|
||||
@@ -318,6 +370,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={publisher.name || ''}
|
||||
onChange={handlePublisherFieldChange('name')}
|
||||
placeholder="Publisher Name"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -340,6 +393,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={publisher.logo || ''}
|
||||
onChange={handlePublisherFieldChange('logo')}
|
||||
placeholder="https://example.com/logo.png"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -348,7 +402,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
|
||||
{/* Publication Dates */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CalendarIcon />
|
||||
Publication Dates
|
||||
@@ -375,6 +429,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={jsonLdSchema.datePublished || ''}
|
||||
onChange={handleSchemaFieldChange('datePublished')}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -398,6 +453,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
value={jsonLdSchema.dateModified || ''}
|
||||
onChange={handleSchemaFieldChange('dateModified')}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -406,7 +462,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
|
||||
{/* Keywords */}
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={{ p: 3, background: '#ffffff', border: '1px solid #e0e0e0', borderRadius: 2, boxShadow: '0 2px 4px rgba(0,0,0,0.04)' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CodeIcon />
|
||||
Keywords & Categories
|
||||
@@ -438,6 +494,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
}}
|
||||
placeholder="keyword1, keyword2, keyword3"
|
||||
helperText="Separate keywords with commas"
|
||||
sx={textInputSx}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@@ -479,7 +536,9 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
||||
readOnly: true,
|
||||
sx: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem'
|
||||
fontSize: '0.875rem',
|
||||
background: '#0f172a',
|
||||
color: '#e2e8f0'
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
|
||||
264
frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx
Normal file
264
frontend/src/components/BlogWriter/SEO/OverallScoreCard.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* OverallScoreCard Component
|
||||
*
|
||||
* Renders the compact overall SEO score summary with grade chip and
|
||||
* category score tiles.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Tooltip,
|
||||
Paper,
|
||||
Chip,
|
||||
Avatar
|
||||
} from '@mui/material';
|
||||
|
||||
interface MetricTooltip {
|
||||
title: string;
|
||||
description: string;
|
||||
methodology: string;
|
||||
score_meaning: string;
|
||||
examples: string;
|
||||
}
|
||||
|
||||
interface OverallScoreCardProps {
|
||||
overallScore: number;
|
||||
overallGrade: string;
|
||||
statusLabel: string;
|
||||
categoryScores: Record<string, number>;
|
||||
getMetricTooltip: (category: string) => MetricTooltip;
|
||||
getScoreColor: (score: number) => string;
|
||||
}
|
||||
|
||||
const getGradeMeta = (grade: string) => {
|
||||
switch (grade) {
|
||||
case 'A':
|
||||
return {
|
||||
color: '#16a34a',
|
||||
background: 'linear-gradient(135deg, rgba(34,197,94,0.12), rgba(22,163,74,0.18))',
|
||||
tooltip: 'Grade A: Outstanding SEO health with only minor optimizations needed.'
|
||||
};
|
||||
case 'B':
|
||||
return {
|
||||
color: '#0ea5e9',
|
||||
background: 'linear-gradient(135deg, rgba(14,165,233,0.12), rgba(2,132,199,0.18))',
|
||||
tooltip: 'Grade B: Strong SEO foundation with several opportunities to optimize further.'
|
||||
};
|
||||
case 'C':
|
||||
return {
|
||||
color: '#d97706',
|
||||
background: 'linear-gradient(135deg, rgba(251,191,36,0.14), rgba(217,119,6,0.2))',
|
||||
tooltip: 'Grade C: Moderate SEO performance. Prioritize improvements in weaker categories.'
|
||||
};
|
||||
case 'D':
|
||||
return {
|
||||
color: '#ea580c',
|
||||
background: 'linear-gradient(135deg, rgba(251,113,133,0.14), rgba(249,115,22,0.2))',
|
||||
tooltip: 'Grade D: Significant SEO gaps detected. Address critical issues promptly.'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: '#475569',
|
||||
background: 'linear-gradient(135deg, rgba(148,163,184,0.14), rgba(100,116,139,0.2))',
|
||||
tooltip: 'SEO grade unavailable. Review analysis details for more information.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const OverallScoreCard: React.FC<OverallScoreCardProps> = ({
|
||||
overallScore,
|
||||
overallGrade,
|
||||
statusLabel,
|
||||
categoryScores,
|
||||
getMetricTooltip,
|
||||
getScoreColor
|
||||
}) => {
|
||||
const gradeMeta = getGradeMeta(overallGrade);
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
mb: 3,
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
boxShadow: '0 8px 24px rgba(15,23,42,0.04)',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<CardHeader
|
||||
sx={{
|
||||
pb: 0,
|
||||
'& .MuiCardHeader-content': {
|
||||
overflow: 'hidden'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}
|
||||
>
|
||||
Overall SEO Performance Snapshot
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent
|
||||
sx={{
|
||||
pt: 2,
|
||||
pb: { xs: 2.5, md: 3 },
|
||||
px: { xs: 2, md: 3 }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'column', md: 'row' },
|
||||
gap: { xs: 3, md: 4 },
|
||||
alignItems: { xs: 'stretch', md: 'flex-start' }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flexShrink: 0,
|
||||
minWidth: { md: 240 },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: { xs: 'flex-start', md: 'center' },
|
||||
gap: 1.5,
|
||||
background: 'linear-gradient(145deg, rgba(241,245,249,0.7), rgba(255,255,255,0.95))',
|
||||
borderRadius: 2,
|
||||
p: { xs: 1.5, md: 2 }
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: { xs: 'left', md: 'center' } }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 1,
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '2.4rem', md: '2.8rem' },
|
||||
lineHeight: 1,
|
||||
background: 'linear-gradient(120deg, #22c55e, #4ade80)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{overallScore}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', fontWeight: 600 }}
|
||||
>
|
||||
/100
|
||||
</Typography>
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mt: 0.5 }}>
|
||||
Overall Score
|
||||
</Typography>
|
||||
</Box>
|
||||
<Tooltip title={gradeMeta.tooltip} arrow placement="top">
|
||||
<Chip
|
||||
label={statusLabel}
|
||||
avatar={
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor: '#fff',
|
||||
color: gradeMeta.color,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{overallGrade}
|
||||
</Avatar>
|
||||
}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
px: 2.2,
|
||||
py: 0.5,
|
||||
letterSpacing: 0.3,
|
||||
color: gradeMeta.color,
|
||||
background: gradeMeta.background
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: 'repeat(2, minmax(110px, 1fr))', sm: 'repeat(3, minmax(110px, 1fr))' },
|
||||
gap: 1.5
|
||||
}}
|
||||
>
|
||||
{Object.entries(categoryScores).map(([category, score]) => {
|
||||
const tooltip = getMetricTooltip(category);
|
||||
return (
|
||||
<Tooltip
|
||||
key={category}
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 0.75, color: '#475569' }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Methodology:</strong> {tooltip.methodology}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Score Meaning:</strong> {tooltip.score_meaning}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Examples:</strong> {tooltip.examples}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1.4,
|
||||
textAlign: 'center',
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 8px 18px rgba(15,23,42,0.06)',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ fontWeight: 800, color: getScoreColor(score), mb: 0.35 }}
|
||||
>
|
||||
{score}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: '#64748b', textTransform: 'capitalize', fontWeight: 600 }}
|
||||
>
|
||||
{category.replace('_', ' ')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverallScoreCard;
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Readability Analysis Component
|
||||
*
|
||||
*
|
||||
* Displays comprehensive readability analysis including readability metrics,
|
||||
* content statistics, sentence/paragraph analysis, and target audience information.
|
||||
*/
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
IconButton,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
MenuBook
|
||||
} from '@mui/icons-material';
|
||||
|
||||
@@ -57,109 +57,186 @@ interface ReadabilityAnalysisProps {
|
||||
};
|
||||
}
|
||||
|
||||
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
||||
detailedAnalysis,
|
||||
visualizationData
|
||||
const cardStyles = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 30px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const sectionTitleSx = {
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
color: '#0f172a',
|
||||
mb: 2
|
||||
} as const;
|
||||
|
||||
const statRowSx = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
py: 0.5
|
||||
} as const;
|
||||
|
||||
const statLabelSx = {
|
||||
color: '#475569',
|
||||
fontWeight: 500
|
||||
} as const;
|
||||
|
||||
const statValueSx = {
|
||||
color: '#0f172a',
|
||||
fontWeight: 700
|
||||
} as const;
|
||||
|
||||
const metricRowSx = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.65rem 0.85rem',
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#f1f5f9',
|
||||
cursor: 'help',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 10px 20px rgba(15,23,42,0.08)'
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
||||
detailedAnalysis,
|
||||
visualizationData
|
||||
}) => {
|
||||
const readabilityMetrics = detailedAnalysis?.readability_analysis?.metrics ?? {};
|
||||
|
||||
const getMetricDetails = (metric: string, value: number) => {
|
||||
const tooltips: Record<string, { description: string; interpretation: string }> = {
|
||||
flesch_reading_ease: {
|
||||
description: 'Measures how easy text is to read (0-100 scale).',
|
||||
interpretation: value >= 80 ? 'Very Easy' : value >= 60 ? 'Standard' : 'Challenging'
|
||||
},
|
||||
flesch_kincaid_grade: {
|
||||
description: 'U.S. grade level required to understand the text.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
gunning_fog: {
|
||||
description: 'Years of formal education needed for comprehension.',
|
||||
interpretation: value <= 12 ? 'Easy' : value <= 16 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
smog_index: {
|
||||
description: 'Estimates the years of education needed to understand the text.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
automated_readability: {
|
||||
description: 'Automated readability score based on characters per word.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
},
|
||||
coleman_liau: {
|
||||
description: 'Readability based on characters per word and sentence length.',
|
||||
interpretation: value <= 8 ? 'Easy' : value <= 12 ? 'Moderate' : 'Advanced'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
tooltips[metric] || {
|
||||
description: 'Readability metric',
|
||||
interpretation: 'No interpretation available'
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatRow = (label: React.ReactNode, value: React.ReactNode) => (
|
||||
<Box sx={statRowSx}>
|
||||
<Typography variant="body2" sx={statLabelSx}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={statValueSx}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<MenuBook sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="h3"
|
||||
sx={{ fontWeight: 700, letterSpacing: 0.3, color: '#0f172a' }}
|
||||
>
|
||||
Readability Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Readability Metrics
|
||||
</Typography>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Readability Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Measures how easy your content is to read and understand.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
|
||||
<strong>Flesch Reading Ease:</strong> 90-100 (Very Easy), 80-89 (Easy), 70-79 (Fairly Easy), 60-69 (Standard)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Average Sentence Length:</strong> 15-20 words is optimal
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
|
||||
<strong>Sentence Length:</strong> 15-20 words is optimal
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Average Syllables per Word:</strong> 1.5-1.7 is ideal
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Syllables per Word:</strong> 1.5-1.7 keeps content approachable
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||
<MenuBook />
|
||||
<MenuBook fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{detailedAnalysis?.readability_analysis?.metrics && Object.keys(detailedAnalysis.readability_analysis.metrics).length > 0 ? (
|
||||
Object.entries(detailedAnalysis.readability_analysis.metrics).map(([metric, value]) => {
|
||||
const getReadabilityTooltip = (metric: string, value: number) => {
|
||||
const tooltips = {
|
||||
flesch_reading_ease: {
|
||||
description: "Measures how easy text is to read (0-100 scale)",
|
||||
interpretation: value >= 80 ? "Very Easy" : value >= 60 ? "Standard" : "Difficult"
|
||||
},
|
||||
flesch_kincaid_grade: {
|
||||
description: "U.S. grade level needed to understand the text",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
gunning_fog: {
|
||||
description: "Years of formal education needed to understand the text",
|
||||
interpretation: value <= 12 ? "Easy" : value <= 16 ? "Moderate" : "Difficult"
|
||||
},
|
||||
smog_index: {
|
||||
description: "Simple Measure of Gobbledygook - readability formula",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
automated_readability: {
|
||||
description: "Automated Readability Index based on character count",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
},
|
||||
coleman_liau: {
|
||||
description: "Readability test based on average sentence length and characters per word",
|
||||
interpretation: value <= 8 ? "Easy" : value <= 12 ? "Moderate" : "Difficult"
|
||||
}
|
||||
};
|
||||
return tooltips[metric as keyof typeof tooltips] || { description: "Readability metric", interpretation: "N/A" };
|
||||
};
|
||||
|
||||
const tooltip = getReadabilityTooltip(metric, value);
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||
{Object.keys(readabilityMetrics).length > 0 ? (
|
||||
Object.entries(readabilityMetrics).map(([metric, value]) => {
|
||||
const { description, interpretation } = getMetricDetails(metric, value);
|
||||
const label = metric.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={metric}
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
{metric.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
<strong>Interpretation:</strong> {tooltip.interpretation}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
<strong>Interpretation:</strong> {interpretation}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', p: 1, borderRadius: 1, background: 'rgba(0,0,0,0.02)', cursor: 'help' }}>
|
||||
<Typography variant="body2" sx={{ textTransform: 'capitalize' }}>
|
||||
<Box sx={metricRowSx}>
|
||||
<Typography variant="body2" sx={{ textTransform: 'capitalize', color: '#334155' }}>
|
||||
{metric.replace('_', ' ')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
{value.toFixed(1)}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -167,116 +244,72 @@ export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
No readability metrics available. This may indicate an issue with the content analysis.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Content Statistics
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Word Count</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Sections</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Sentences</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Unique Words</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Vocabulary Diversity</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
|
||||
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow('Word Count', detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count || 'N/A')}
|
||||
{renderStatRow('Sections', detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections || 'N/A')}
|
||||
{renderStatRow('Paragraphs', detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs || 'N/A')}
|
||||
{renderStatRow('Sentences', detailedAnalysis?.content_structure?.total_sentences || 'N/A')}
|
||||
{renderStatRow('Unique Words', detailedAnalysis?.content_quality?.unique_words || 'N/A')}
|
||||
{renderStatRow(
|
||||
'Vocabulary Diversity',
|
||||
detailedAnalysis?.content_quality?.vocabulary_diversity !== undefined
|
||||
? `${(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1)}%`
|
||||
: 'N/A'
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Additional Readability Metrics */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Sentence & Paragraph Analysis
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Avg Sentence Length</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.avg_sentence_length?.toFixed(1) || 'N/A'} words
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Avg Paragraph Length</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.avg_paragraph_length?.toFixed(1) || 'N/A'} words
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Transition Words</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow(
|
||||
'Average Sentence Length',
|
||||
detailedAnalysis?.readability_analysis?.avg_sentence_length !== undefined
|
||||
? `${detailedAnalysis.readability_analysis.avg_sentence_length.toFixed(1)} words`
|
||||
: 'N/A'
|
||||
)}
|
||||
{renderStatRow(
|
||||
'Average Paragraph Length',
|
||||
detailedAnalysis?.readability_analysis?.avg_paragraph_length !== undefined
|
||||
? `${detailedAnalysis.readability_analysis.avg_paragraph_length.toFixed(1)} words`
|
||||
: 'N/A'
|
||||
)}
|
||||
{renderStatRow(
|
||||
'Transition Words Used',
|
||||
detailedAnalysis?.content_quality?.transition_words_used || 'N/A'
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={cardStyles}>
|
||||
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||
Target Audience
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Reading Level</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.readability_analysis?.target_audience || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Content Depth Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="body2">Flow Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{renderStatRow('Reading Level', detailedAnalysis?.readability_analysis?.target_audience || 'N/A')}
|
||||
{renderStatRow('Content Depth Score', detailedAnalysis?.content_quality?.content_depth_score || 'N/A')}
|
||||
{renderStatRow('Flow Score', detailedAnalysis?.content_quality?.flow_score || 'N/A')}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Recommendations Component
|
||||
*
|
||||
*
|
||||
* Displays actionable SEO recommendations with priority indicators,
|
||||
* category tags, and impact descriptions.
|
||||
*/
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Paper,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
Lightbulb,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
@@ -30,78 +30,107 @@ interface RecommendationsProps {
|
||||
recommendations: Recommendation[];
|
||||
}
|
||||
|
||||
const priorityStyles: Record<string, { color: string; gradient: string }> = {
|
||||
High: { color: '#dc2626', gradient: 'linear-gradient(135deg, rgba(248,113,113,0.12), rgba(239,68,68,0.18))' },
|
||||
Medium: { color: '#d97706', gradient: 'linear-gradient(135deg, rgba(251,191,36,0.12), rgba(217,119,6,0.16))' },
|
||||
Low: { color: '#16a34a', gradient: 'linear-gradient(135deg, rgba(74,222,128,0.12), rgba(22,163,74,0.16))' },
|
||||
default: { color: '#475569', gradient: 'linear-gradient(135deg, rgba(148,163,184,0.1), rgba(100,116,139,0.14))' }
|
||||
};
|
||||
|
||||
export const Recommendations: React.FC<RecommendationsProps> = ({ recommendations }) => {
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High': return 'error.main';
|
||||
case 'Medium': return 'warning.main';
|
||||
case 'Low': return 'success.main';
|
||||
default: return 'text.secondary';
|
||||
}
|
||||
};
|
||||
const getPriorityColor = (priority: string) => priorityStyles[priority]?.color || priorityStyles.default.color;
|
||||
|
||||
const getPriorityIcon = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High': return <Cancel sx={{ fontSize: 16 }} />;
|
||||
case 'Medium': return <Warning sx={{ fontSize: 16 }} />;
|
||||
case 'Low': return <CheckCircle sx={{ fontSize: 16 }} />;
|
||||
default: return <Warning sx={{ fontSize: 16 }} />;
|
||||
case 'High':
|
||||
return <Cancel sx={{ fontSize: 18 }} />;
|
||||
case 'Medium':
|
||||
return <Warning sx={{ fontSize: 18 }} />;
|
||||
case 'Low':
|
||||
return <CheckCircle sx={{ fontSize: 18 }} />;
|
||||
default:
|
||||
return <Warning sx={{ fontSize: 18 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getScoreBadgeVariant = (score: number) => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 60) return 'warning';
|
||||
return 'error';
|
||||
const getChipColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'High':
|
||||
return 'error';
|
||||
case 'Medium':
|
||||
return 'warning';
|
||||
case 'Low':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Lightbulb sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Actionable Recommendations
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{recommendations.map((rec, index) => (
|
||||
<Paper
|
||||
key={index}
|
||||
sx={{
|
||||
p: 3,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ color: getPriorityColor(rec.priority), mt: 0.5 }}>
|
||||
{getPriorityIcon(rec.priority)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Chip
|
||||
label={rec.category}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.priority}
|
||||
color={getScoreBadgeVariant(rec.priority === 'High' ? 30 : 70)}
|
||||
size="small"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{recommendations.map((rec, index) => {
|
||||
const styles = priorityStyles[rec.priority] || priorityStyles.default;
|
||||
return (
|
||||
<Paper
|
||||
key={index}
|
||||
sx={{
|
||||
p: 3,
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 16px 36px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '999px',
|
||||
background: styles.gradient,
|
||||
color: getPriorityColor(rec.priority)
|
||||
}}
|
||||
>
|
||||
{getPriorityIcon(rec.priority)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
|
||||
<Chip
|
||||
label={rec.category}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{ borderColor: '#cbd5f5', color: '#475569', fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.priority}
|
||||
color={getChipColor(rec.priority)}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ lineHeight: 1.6, color: '#1f2937' }}>
|
||||
{rec.recommendation}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{rec.impact}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{rec.recommendation}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{rec.impact}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Structure Analysis Component
|
||||
*
|
||||
*
|
||||
* Displays comprehensive content structure analysis including structure overview,
|
||||
* content elements detection, and heading structure analysis.
|
||||
*/
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
import {
|
||||
BarChart
|
||||
} from '@mui/icons-material';
|
||||
|
||||
@@ -52,127 +52,157 @@ interface StructureAnalysisProps {
|
||||
};
|
||||
}
|
||||
|
||||
const baseCard = {
|
||||
p: 3,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 12px 28px rgba(15,23,42,0.08)',
|
||||
color: '#0f172a',
|
||||
minHeight: '100%'
|
||||
} as const;
|
||||
|
||||
const infoRow = {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem 0',
|
||||
cursor: 'help'
|
||||
} as const;
|
||||
|
||||
const statLabel = {
|
||||
color: '#475569',
|
||||
fontWeight: 500
|
||||
} as const;
|
||||
|
||||
const statValue = {
|
||||
color: '#0f172a',
|
||||
fontWeight: 700
|
||||
} as const;
|
||||
|
||||
const highlightCard = (borderColor: string) => ({
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${borderColor}`,
|
||||
background: `linear-gradient(140deg, ${borderColor}15, ${borderColor}22)`
|
||||
});
|
||||
|
||||
export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAnalysis }) => {
|
||||
const structure = detailedAnalysis?.content_structure;
|
||||
const quality = detailedAnalysis?.content_quality;
|
||||
const headings = detailedAnalysis?.heading_structure;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<BarChart sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, letterSpacing: 0.2, color: '#0f172a' }}>
|
||||
Content Structure Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={3}>
|
||||
{/* Content Structure Overview */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Structure Overview
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Sections
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Number of main content sections (H2 headings) in your blog post.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 3-8 sections for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Good sectioning improves readability and helps search engines understand your content structure.
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Why it matters:</strong> Good sectioning improves readability and structure.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Total Sections</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sections || 'N/A'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Sections</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_sections || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Paragraphs
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Number of paragraphs in your content (excluding headings).
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 8-20 paragraphs for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Appropriate paragraph count indicates good content depth and organization.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Total Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_paragraphs || 'N/A'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Paragraphs</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_paragraphs || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Total Sentences
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Total number of sentences in your content.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Optimal Range:</strong> 40-100 sentences for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Sentence count affects readability and content comprehensiveness.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Total Sentences</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Total Sentences</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.total_sentences || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||
Structure Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||
Overall score (0-100) for your content's structural organization.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Scoring Factors:</strong> Section count, paragraph count, introduction/conclusion presence
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Well-structured content ranks better and provides better user experience.
|
||||
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||
<strong>Scoring Factors:</strong> Section count, paragraph count, intro/conclusion presence.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Structure Score</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{detailedAnalysis?.content_structure?.structure_score || 'N/A'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Structure Score</Typography>
|
||||
<Typography variant="body2" sx={statValue}>
|
||||
{structure?.structure_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -182,94 +212,52 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
||||
|
||||
{/* Content Elements */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Content Elements
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Introduction Section
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Whether your content has a clear introduction that sets context and expectations.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Why it matters:</strong> Introductions help readers understand what they'll learn and improve engagement.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>SEO Impact:</strong> Clear introductions help search engines understand your content's purpose.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Whether your content has a clear introduction that sets context and expectations."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Has Introduction</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_introduction ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_introduction ? 'success' : 'error'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Introduction</Typography>
|
||||
<Chip
|
||||
label={structure?.has_introduction ? 'Yes' : 'No'}
|
||||
color={structure?.has_introduction ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Conclusion Section
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Whether your content has a clear conclusion that summarizes key points.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Why it matters:</strong> Conclusions help readers retain information and provide closure.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>SEO Impact:</strong> Good conclusions can improve time on page and reduce bounce rate.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Whether your content ends with a clear conclusion summarizing key points."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Has Conclusion</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_conclusion ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_conclusion ? 'success' : 'error'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Conclusion</Typography>
|
||||
<Chip
|
||||
label={structure?.has_conclusion ? 'Yes' : 'No'}
|
||||
color={structure?.has_conclusion ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Call to Action
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Whether your content includes a clear call to action for readers.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Why it matters:</strong> CTAs guide readers to take desired actions and improve conversion rates.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>SEO Impact:</strong> Strong CTAs can improve user engagement metrics.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Whether your content includes a clear call to action for readers."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
||||
<Typography variant="body2">Has Call to Action</Typography>
|
||||
<Chip
|
||||
label={detailedAnalysis?.content_structure?.has_call_to_action ? 'Yes' : 'No'}
|
||||
color={detailedAnalysis?.content_structure?.has_call_to_action ? 'success' : 'error'}
|
||||
<Box sx={infoRow}>
|
||||
<Typography variant="body2" sx={statLabel}>Has Call to Action</Typography>
|
||||
<Chip
|
||||
label={structure?.has_call_to_action ? 'Yes' : 'No'}
|
||||
color={structure?.has_call_to_action ? 'success' : 'error'}
|
||||
size="small"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -281,193 +269,104 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
||||
{/* Content Quality Metrics */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Content Quality Metrics
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Word Count
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Total number of words in your content.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Optimal Range:</strong> 800-2000 words for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Longer content typically ranks better and provides more value to readers.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Total number of words in your content. Longer content typically ranks better."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(34,197,94,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
|
||||
Word Count
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.word_count || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.word_count || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Vocabulary Diversity
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Ratio of unique words to total words, indicating content variety.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Optimal Range:</strong> 0.4-0.7 (40-70% unique words)
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Higher diversity indicates richer, more engaging content.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Ratio of unique words to total words, indicating content variety and richness."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(59,130,246,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
|
||||
Vocabulary Diversity
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
|
||||
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.vocabulary_diversity !== undefined
|
||||
? `${(quality.vocabulary_diversity * 100).toFixed(1)}%`
|
||||
: 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Content Depth Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Score (0-100) indicating how comprehensive and detailed your content is.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Scoring Factors:</strong> Word count, section depth, information density
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Deeper content provides more value and ranks better in search results.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Score (0-100) indicating how comprehensive and detailed your content is."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(168,85,247,0.65)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed', mb: 1 }}>
|
||||
Content Depth Score
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.content_depth_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Flow Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Score (0-100) indicating how well your content flows from one idea to the next.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Scoring Factors:</strong> Transition words, sentence variety, logical progression
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Good flow improves readability and keeps readers engaged.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Score (0-100) indicating how well your content flows from one idea to the next."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(255, 152, 0, 0.1)', borderRadius: 2, border: '1px solid rgba(255, 152, 0, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'warning.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(14,165,233,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0284c7', mb: 1 }}>
|
||||
Flow Score
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.flow_score || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Transition Words
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Number of transition words used to connect ideas and improve flow.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Optimal Range:</strong> 5-15 transition words for most blog posts
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Transition words improve readability and help readers follow your logic.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Number of transition words used – higher values suggest smoother narrative flow."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(244, 67, 54, 0.1)', borderRadius: 2, border: '1px solid rgba(244, 67, 54, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'error.main', mb: 1 }}>
|
||||
Transition Words
|
||||
<Box sx={highlightCard('rgba(251,191,36,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#b45309', mb: 1 }}>
|
||||
Transition Words Used
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.transition_words_used || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Unique Words
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Number of unique words used in your content.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Why it matters:</strong> More unique words indicate richer vocabulary and better content variety.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>SEO Impact:</strong> Diverse vocabulary can help with semantic SEO and topic coverage.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
title="Average unique words used throughout the article. Indicates lexical richness."
|
||||
arrow
|
||||
>
|
||||
<Box sx={{ p: 2, background: 'rgba(0, 150, 136, 0.1)', borderRadius: 2, border: '1px solid rgba(0, 150, 136, 0.3)', cursor: 'help' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'info.main', mb: 1 }}>
|
||||
<Box sx={highlightCard('rgba(244,114,182,0.6)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#be185d', mb: 1 }}>
|
||||
Unique Words
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
||||
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{quality?.unique_words || 'N/A'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
@@ -478,136 +377,58 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
||||
</Grid>
|
||||
|
||||
{/* Heading Structure */}
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Heading Structure Analysis
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
||||
H1 Headings ({detailedAnalysis?.heading_structure?.h1_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h1_headings?.map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
{headings && (
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12}>
|
||||
<Paper sx={baseCard}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||
Heading Structure
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(59,130,246,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
|
||||
H1 Headings
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
||||
H2 Headings ({detailedAnalysis?.heading_structure?.h2_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h2_headings?.slice(0, 3).map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h1_count}
|
||||
</Typography>
|
||||
))}
|
||||
{detailedAnalysis?.heading_structure?.h2_headings && detailedAnalysis.heading_structure.h2_headings.length > 3 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
... and {detailedAnalysis.heading_structure.h2_headings.length - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ p: 2, background: 'rgba(156, 39, 176, 0.1)', borderRadius: 2, border: '1px solid rgba(156, 39, 176, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
||||
H3 Headings ({detailedAnalysis?.heading_structure?.h3_count || 0})
|
||||
</Typography>
|
||||
{detailedAnalysis?.heading_structure?.h3_headings?.slice(0, 3).map((heading: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
• {heading}
|
||||
</Typography>
|
||||
))}
|
||||
{detailedAnalysis?.heading_structure?.h3_headings && detailedAnalysis.heading_structure.h3_headings.length > 3 && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
... and {detailedAnalysis.heading_structure.h3_headings.length - 3} more
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Heading Hierarchy Score
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Score (0-100) indicating how well your heading structure follows SEO best practices.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Scoring Factors:</strong> H1 presence, logical hierarchy, keyword usage in headings
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Why it matters:</strong> Good heading structure helps search engines understand your content and improves readability.
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h1_headings?.[0] ? `Primary: ${headings.h1_headings[0]}` : 'Primary heading analysis'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, cursor: 'help' }}>
|
||||
Heading Hierarchy Score: {detailedAnalysis?.heading_structure?.heading_hierarchy_score || 'N/A'}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Structure Recommendations */}
|
||||
{detailedAnalysis?.content_structure?.recommendations && detailedAnalysis.content_structure.recommendations.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(255, 193, 7, 0.1)', borderRadius: 2, border: '1px solid rgba(255, 193, 7, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'warning.main' }}>
|
||||
Structure Recommendations
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{detailedAnalysis.content_structure.recommendations.map((recommendation: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
|
||||
• {recommendation}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(34,197,94,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
|
||||
H2 Headings
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Heading Recommendations */}
|
||||
{detailedAnalysis?.heading_structure?.recommendations && detailedAnalysis.heading_structure.recommendations.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(33, 150, 243, 0.1)', borderRadius: 2, border: '1px solid rgba(33, 150, 243, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'primary.main' }}>
|
||||
Heading Recommendations
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{detailedAnalysis.heading_structure.recommendations.map((recommendation: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
|
||||
• {recommendation}
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h2_count}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Content Quality Recommendations */}
|
||||
{detailedAnalysis?.content_quality?.recommendations && detailedAnalysis.content_quality.recommendations.length > 0 && (
|
||||
<Box sx={{ mt: 2, p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: 'success.main' }}>
|
||||
Content Quality Recommendations
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{detailedAnalysis.content_quality.recommendations.map((recommendation: string, index: number) => (
|
||||
<Typography key={index} variant="caption" sx={{ display: 'block' }}>
|
||||
• {recommendation}
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h2_headings?.slice(0, 2).join(', ') || 'Summary of subtopics'}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={highlightCard('rgba(14,165,233,0.45)')}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0ea5e9', mb: 1 }}>
|
||||
H3 Headings
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||
{headings.h3_count}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||
{headings.h3_headings?.slice(0, 2).join(', ') || 'Supportive outline points'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
Grid,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Avatar,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import { apiClient } from '../../api/client';
|
||||
import {
|
||||
@@ -32,11 +34,11 @@ import {
|
||||
Warning,
|
||||
TrendingUp,
|
||||
Search,
|
||||
BarChart,
|
||||
Refresh,
|
||||
Close
|
||||
} from '@mui/icons-material';
|
||||
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
|
||||
import OverallScoreCard from './SEO/OverallScoreCard';
|
||||
|
||||
interface SEOAnalysisResult {
|
||||
overall_score: number;
|
||||
@@ -139,7 +141,27 @@ interface SEOAnalysisModalProps {
|
||||
blogContent: string;
|
||||
blogTitle?: string;
|
||||
researchData: any;
|
||||
onApplyRecommendations?: (recommendations: any[]) => void;
|
||||
onApplyRecommendations?: (recommendations: SEOAnalysisResult['actionable_recommendations']) => Promise<void>;
|
||||
onAnalysisComplete?: (analysis: SEOAnalysisResult) => void;
|
||||
}
|
||||
|
||||
// Simple content hashing helper (SHA-256)
|
||||
async function hashContent(text: string): Promise<string> {
|
||||
try {
|
||||
const enc = new TextEncoder().encode(text);
|
||||
const digest = await crypto.subtle.digest('SHA-256', enc);
|
||||
const bytes = Array.from(new Uint8Array(digest));
|
||||
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} catch {
|
||||
// Fallback hash
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
return String(h);
|
||||
}
|
||||
}
|
||||
|
||||
function getSeoCacheKey(contentHash: string, title?: string) {
|
||||
return `seo_cache:${contentHash}:${title || ''}`;
|
||||
}
|
||||
|
||||
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
@@ -148,7 +170,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
blogContent,
|
||||
blogTitle,
|
||||
researchData,
|
||||
onApplyRecommendations
|
||||
onApplyRecommendations,
|
||||
onAnalysisComplete
|
||||
}) => {
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
|
||||
@@ -156,18 +179,37 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState('recommendations');
|
||||
const [contentHash, setContentHash] = useState<string>('');
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
|
||||
// Debug logging
|
||||
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||
|
||||
const runSEOAnalysis = useCallback(async () => {
|
||||
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
setIsAnalyzing(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setProgressMessage('Starting SEO analysis...');
|
||||
|
||||
// Simulate progress updates (in real implementation, this would be SSE)
|
||||
// Cache check
|
||||
const hash = contentHash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
|
||||
const cacheKey = getSeoCacheKey(hash, blogTitle);
|
||||
if (!forceRefresh) {
|
||||
const cached = typeof window !== 'undefined' ? window.localStorage.getItem(cacheKey) : null;
|
||||
if (cached) {
|
||||
const parsed = JSON.parse(cached);
|
||||
setAnalysisResult(parsed as SEOAnalysisResult);
|
||||
setIsAnalyzing(false);
|
||||
// Notify parent that analysis is complete (from cache)
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(parsed as SEOAnalysisResult);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Simulated progress
|
||||
const progressStages = [
|
||||
{ progress: 20, message: 'Extracting keywords from research data...' },
|
||||
{ progress: 40, message: 'Analyzing content structure and readability...' },
|
||||
@@ -182,7 +224,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
// Make API call to analyze blog content
|
||||
// Backend call
|
||||
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
|
||||
blog_content: blogContent,
|
||||
blog_title: blogTitle,
|
||||
@@ -191,15 +233,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
|
||||
const result = response.data;
|
||||
console.log('🔍 Backend SEO Analysis Response:', result);
|
||||
|
||||
// Convert API response to frontend format - fail fast if data is missing
|
||||
if (!result.success) {
|
||||
throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
|
||||
}
|
||||
|
||||
if (!result.overall_score && result.overall_score !== 0) {
|
||||
throw new Error('Invalid SEO score received from API');
|
||||
}
|
||||
if (!result.success) throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
|
||||
if (!result.overall_score && result.overall_score !== 0) throw new Error('Invalid SEO score received from API');
|
||||
|
||||
const convertedResult: SEOAnalysisResult = {
|
||||
overall_score: result.overall_score,
|
||||
@@ -256,13 +291,44 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
};
|
||||
|
||||
setAnalysisResult(convertedResult);
|
||||
|
||||
// Save to cache
|
||||
try {
|
||||
const h = hash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
|
||||
const key = getSeoCacheKey(h, blogTitle);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(key, JSON.stringify(convertedResult));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setIsAnalyzing(false);
|
||||
|
||||
// Notify parent that analysis is complete (fresh analysis)
|
||||
if (onAnalysisComplete) {
|
||||
onAnalysisComplete(convertedResult);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Analysis failed');
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
}, [blogContent, blogTitle, researchData]);
|
||||
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
|
||||
|
||||
// Precompute hash when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
(async () => {
|
||||
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(h);
|
||||
})();
|
||||
}
|
||||
}, [isOpen, blogContent, blogTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !analysisResult) {
|
||||
runSEOAnalysis();
|
||||
}
|
||||
}, [isOpen, analysisResult, runSEOAnalysis]);
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 80) return 'success.main';
|
||||
@@ -270,13 +336,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
return 'error.main';
|
||||
};
|
||||
|
||||
const getScoreBadgeVariant = (score: number) => {
|
||||
if (score >= 80) return 'success';
|
||||
if (score >= 60) return 'warning';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
|
||||
// Tooltip content for each metric
|
||||
const getMetricTooltip = (category: string) => {
|
||||
const tooltips = {
|
||||
@@ -326,12 +385,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !analysisResult) {
|
||||
runSEOAnalysis();
|
||||
}
|
||||
}, [isOpen, analysisResult, runSEOAnalysis]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
@@ -342,14 +395,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
sx: {
|
||||
maxHeight: '90vh',
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.98)',
|
||||
backgroundColor: '#f8fafc',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
color: 'text.primary'
|
||||
border: '1px solid rgba(148,163,184,0.25)',
|
||||
color: '#0f172a'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
<DialogContent sx={{ p: 0, color: '#0f172a' }}>
|
||||
<Box sx={{ p: 3, borderBottom: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
@@ -358,9 +411,22 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
SEO Analysis Results
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => {
|
||||
setAnalysisResult(null);
|
||||
runSEOAnalysis(true);
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||
Comprehensive analysis of your blog content's SEO optimization
|
||||
@@ -410,138 +476,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
{analysisResult && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{/* Overall Score Section */}
|
||||
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<CardHeader>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BarChart sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Overall SEO Score
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: getScoreColor(analysisResult.overall_score),
|
||||
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
{analysisResult.overall_score}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Overall Score
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
{analysisResult.analysis_summary.overall_grade}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Grade
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Chip
|
||||
label={analysisResult.analysis_summary.status}
|
||||
color={getScoreBadgeVariant(analysisResult.overall_score)}
|
||||
variant="filled"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
px: 2,
|
||||
py: 1
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category Scores */}
|
||||
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
||||
<CardHeader>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
Category Breakdown
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
{Object.entries(analysisResult.category_scores).map(([category, score]) => {
|
||||
const tooltip = getMetricTooltip(category);
|
||||
return (
|
||||
<Grid item xs={6} md={4} key={category}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
{tooltip.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
{tooltip.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1, fontStyle: 'italic' }}>
|
||||
<strong>Methodology:</strong> {tooltip.methodology}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
<strong>Score Meaning:</strong> {tooltip.score_meaning}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
<strong>Examples:</strong> {tooltip.examples}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
background: 'rgba(255,255,255,0.8)',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
borderRadius: 2,
|
||||
cursor: 'help',
|
||||
'&:hover': {
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
transform: 'translateY(-2px)',
|
||||
transition: 'all 0.2s ease-in-out'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: getScoreColor(score),
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
{score}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', textTransform: 'capitalize' }}>
|
||||
{category.replace('_', ' ')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OverallScoreCard
|
||||
overallScore={analysisResult.overall_score}
|
||||
overallGrade={analysisResult.analysis_summary.overall_grade}
|
||||
statusLabel={analysisResult.analysis_summary.status}
|
||||
categoryScores={analysisResult.category_scores}
|
||||
getMetricTooltip={getMetricTooltip}
|
||||
getScoreColor={getScoreColor}
|
||||
/>
|
||||
|
||||
{/* Detailed Analysis Tabs */}
|
||||
<Card sx={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
@@ -603,43 +545,41 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||
AI-Powered Insights
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Content Summary
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.6 }}>
|
||||
{analysisResult.analysis_summary.ai_summary}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Key Strengths
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||
<Typography variant="body2">{strength}</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#1f2937' }}>{strength}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||
Areas for Improvement
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
|
||||
<Typography variant="body2">{weakness}</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#1f2937' }}>{weakness}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
@@ -652,19 +592,35 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ p: 3, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{applyError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
<Cancel sx={{ mr: 1 }} />
|
||||
{applyError}
|
||||
</Alert>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||
<Button variant="outlined" onClick={onClose} sx={{ color: 'text.secondary' }} disabled={isApplying}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
if (onApplyRecommendations) {
|
||||
onApplyRecommendations(analysisResult.actionable_recommendations);
|
||||
onClick={async () => {
|
||||
if (!onApplyRecommendations) return;
|
||||
setApplyError(null);
|
||||
setIsApplying(true);
|
||||
try {
|
||||
await onApplyRecommendations(analysisResult.actionable_recommendations);
|
||||
// Increased delay to ensure sections are fully updated and phase stays in SEO
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 200);
|
||||
} catch (applyErr: any) {
|
||||
setApplyError(applyErr?.message || 'Failed to apply recommendations.');
|
||||
} finally {
|
||||
setIsApplying(false);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
disabled={!onApplyRecommendations}
|
||||
disabled={!onApplyRecommendations || isApplying}
|
||||
sx={{
|
||||
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
||||
'&:hover': {
|
||||
@@ -672,7 +628,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply Recommendations
|
||||
{isApplying ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
Applying...
|
||||
</Box>
|
||||
) : (
|
||||
'Apply Recommendations'
|
||||
)}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - Integration with backend metadata generation
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
CircularProgress,
|
||||
Alert,
|
||||
IconButton,
|
||||
Chip
|
||||
Chip,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
@@ -42,6 +43,7 @@ import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab';
|
||||
import { SocialMediaTab } from './SEO/MetadataDisplay/SocialMediaTab';
|
||||
import { StructuredDataTab } from './SEO/MetadataDisplay/StructuredDataTab';
|
||||
import { PreviewCard } from './SEO/MetadataDisplay/PreviewCard';
|
||||
import { subscribeImage } from '../../utils/imageBus';
|
||||
|
||||
interface SEOMetadataModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -49,6 +51,8 @@ interface SEOMetadataModalProps {
|
||||
blogContent: string;
|
||||
blogTitle: string;
|
||||
researchData: any;
|
||||
outline?: any[]; // Add outline structure
|
||||
seoAnalysis?: any; // Add SEO analysis results
|
||||
onMetadataGenerated: (metadata: any) => void;
|
||||
}
|
||||
|
||||
@@ -71,20 +75,55 @@ interface SEOMetadataResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Cache helper functions (similar to SEOAnalysisModal)
|
||||
async function hashContent(text: string): Promise<string> {
|
||||
try {
|
||||
const enc = new TextEncoder().encode(text);
|
||||
const digest = await crypto.subtle.digest('SHA-256', enc);
|
||||
const bytes = Array.from(new Uint8Array(digest));
|
||||
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} catch {
|
||||
// Fallback hash
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
return String(h);
|
||||
}
|
||||
}
|
||||
|
||||
function getMetadataCacheKey(contentHash: string, title?: string): string {
|
||||
return `seo_metadata_cache:${contentHash}:${title || ''}`;
|
||||
}
|
||||
|
||||
export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
blogContent,
|
||||
blogTitle,
|
||||
researchData,
|
||||
outline,
|
||||
seoAnalysis,
|
||||
onMetadataGenerated
|
||||
}) => {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [metadataResult, setMetadataResult] = useState<SEOMetadataResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tabValue, setTabValue] = useState('core');
|
||||
const [tabValue, setTabValue] = useState('preview'); // Start with preview tab first
|
||||
const [previewTabValue, setPreviewTabValue] = useState('google'); // Sub-tab for preview platforms
|
||||
const [copiedItems, setCopiedItems] = useState<Set<string>>(new Set());
|
||||
const [editableMetadata, setEditableMetadata] = useState<SEOMetadataResult | null>(null);
|
||||
const [contentHash, setContentHash] = useState<string>('');
|
||||
// Subscribe to image generation bus to auto-fill OG/Twitter image fields
|
||||
useEffect(() => {
|
||||
const unsub = subscribeImage(({ base64 }: { base64: string }) => {
|
||||
setEditableMetadata(prev => {
|
||||
const next = { ...(prev || metadataResult || {}) } as any;
|
||||
next.open_graph = { ...(next.open_graph || {}), image: `data:image/png;base64,${base64}` };
|
||||
next.twitter_card = { ...(next.twitter_card || {}), image: `data:image/png;base64,${base64}` };
|
||||
return next;
|
||||
});
|
||||
});
|
||||
return unsub;
|
||||
}, [metadataResult]);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
@@ -96,19 +135,67 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
});
|
||||
}, [isOpen, blogContent, blogTitle, researchData]);
|
||||
|
||||
const generateMetadata = async () => {
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes (but keep result for next time)
|
||||
setError(null);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Auto-generate metadata when modal opens (only once)
|
||||
const hasAutoGeneratedRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (isOpen && blogContent && !hasAutoGeneratedRef.current) {
|
||||
hasAutoGeneratedRef.current = true;
|
||||
generateMetadata(false); // Auto-generate from cache or API
|
||||
}
|
||||
if (!isOpen) {
|
||||
hasAutoGeneratedRef.current = false; // Reset when modal closes
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]); // Only trigger when modal opens
|
||||
|
||||
const generateMetadata = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
setMetadataResult(null);
|
||||
if (forceRefresh) {
|
||||
setMetadataResult(null);
|
||||
}
|
||||
|
||||
console.log('🚀 Starting SEO metadata generation...');
|
||||
console.log('🚀 Starting SEO metadata generation...', { forceRefresh });
|
||||
|
||||
// Calculate content hash for caching
|
||||
const hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
|
||||
setContentHash(hash);
|
||||
const cacheKey = getMetadataCacheKey(hash, blogTitle);
|
||||
|
||||
// Check cache first (unless force refresh)
|
||||
if (!forceRefresh && typeof window !== 'undefined') {
|
||||
const cached = window.localStorage.getItem(cacheKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as SEOMetadataResult;
|
||||
console.log('✅ Using cached SEO metadata');
|
||||
setMetadataResult(parsed);
|
||||
setEditableMetadata(parsed);
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse cached metadata:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make API call to generate metadata
|
||||
const response = await apiClient.post('/api/blog/seo/metadata', {
|
||||
content: blogContent,
|
||||
title: blogTitle,
|
||||
research_data: researchData
|
||||
research_data: researchData,
|
||||
outline: outline || null,
|
||||
seo_analysis: seoAnalysis || null
|
||||
});
|
||||
|
||||
const result = response.data;
|
||||
@@ -118,6 +205,16 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
throw new Error(result.error || 'Metadata generation failed');
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(cacheKey, JSON.stringify(result));
|
||||
console.log('💾 SEO metadata cached');
|
||||
} catch (e) {
|
||||
console.warn('Failed to cache metadata:', e);
|
||||
}
|
||||
}
|
||||
|
||||
setMetadataResult(result);
|
||||
setEditableMetadata(result);
|
||||
console.log('📊 Metadata result set:', result);
|
||||
@@ -128,7 +225,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
}, [blogContent, blogTitle, researchData, outline, seoAnalysis]);
|
||||
|
||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||
setTabValue(newValue);
|
||||
@@ -159,6 +256,23 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle Apply Metadata button click
|
||||
*
|
||||
* This saves the generated/edited metadata to the parent component's state.
|
||||
* The metadata is then used when publishing to platforms:
|
||||
* - WordPress: Requires SEO metadata for proper post creation with SEO fields
|
||||
* - Wix: Currently doesn't require metadata, but could be added in future
|
||||
*
|
||||
* The metadata includes:
|
||||
* - SEO title, meta description, URL slug
|
||||
* - Blog tags, categories, focus keyword
|
||||
* - Open Graph tags (Facebook/LinkedIn)
|
||||
* - Twitter Card tags
|
||||
* - JSON-LD structured data (Schema.org Article)
|
||||
*
|
||||
* All of these will be passed to the platform's API when publishing.
|
||||
*/
|
||||
const handleApplyMetadata = () => {
|
||||
if (editableMetadata) {
|
||||
onMetadataGenerated(editableMetadata);
|
||||
@@ -222,32 +336,26 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{metadataResult && (
|
||||
<Tooltip title="Regenerate SEO metadata">
|
||||
<IconButton
|
||||
onClick={() => generateMetadata(true)}
|
||||
size="small"
|
||||
disabled={isGenerating}
|
||||
color="primary"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
{!metadataResult && !isGenerating && (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Generate Comprehensive SEO Metadata
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3, color: 'text.secondary' }}>
|
||||
Create optimized titles, descriptions, Open Graph tags, Twitter cards, and structured data for your blog post.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={generateMetadata}
|
||||
startIcon={<RefreshIcon />}
|
||||
sx={{ px: 4 }}
|
||||
>
|
||||
Generate SEO Metadata
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isGenerating && (
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<CircularProgress size={60} sx={{ mb: 2 }} />
|
||||
@@ -267,7 +375,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
</Alert>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={generateMetadata}
|
||||
onClick={() => generateMetadata(true)}
|
||||
startIcon={<RefreshIcon />}
|
||||
>
|
||||
Try Again
|
||||
@@ -286,7 +394,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
scrollButtons="auto"
|
||||
sx={{ minHeight: 48 }}
|
||||
>
|
||||
{['core', 'social', 'structured', 'preview'].map((tab) => (
|
||||
{['preview', 'core', 'social', 'structured'].map((tab) => (
|
||||
<Tab
|
||||
key={tab}
|
||||
value={tab}
|
||||
@@ -332,6 +440,8 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
<PreviewCard
|
||||
metadata={editableMetadata || metadataResult}
|
||||
blogTitle={blogTitle}
|
||||
previewTabValue={previewTabValue}
|
||||
onPreviewTabChange={setPreviewTabValue}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -10,10 +10,19 @@ const SEOMiniPanel: React.FC<Props> = ({ analysis }) => {
|
||||
return (
|
||||
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
|
||||
<div>Score: {analysis.seo_score}</div>
|
||||
{!!analysis.recommendations?.length && (
|
||||
<div>Score: {analysis.overall_score}</div>
|
||||
{!!analysis.analysis_summary && (
|
||||
<div style={{ fontSize: 12, color: '#555', marginTop: 4 }}>
|
||||
Grade {analysis.analysis_summary.overall_grade} · {analysis.analysis_summary.status}
|
||||
</div>
|
||||
)}
|
||||
{!!analysis.actionable_recommendations?.length && (
|
||||
<ul>
|
||||
{analysis.recommendations.slice(0, 3).map((r, i) => (<li key={i}>{r}</li>))}
|
||||
{analysis.actionable_recommendations.slice(0, 3).map((rec, index) => (
|
||||
<li key={index}>
|
||||
<strong>{rec.category}:</strong> {rec.recommendation}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,17 +13,35 @@ interface SuggestionsGeneratorProps {
|
||||
contentConfirmed?: boolean;
|
||||
}
|
||||
|
||||
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 },
|
||||
hasContent: boolean = false,
|
||||
flowAnalysisCompleted: boolean = false,
|
||||
contentConfirmed: boolean = false
|
||||
) => {
|
||||
interface SuggestionContext {
|
||||
research: BlogResearchResponse | null;
|
||||
outline: BlogOutlineSection[];
|
||||
outlineConfirmed?: boolean;
|
||||
researchPolling?: { isPolling: boolean; currentStatus: string };
|
||||
outlinePolling?: { isPolling: boolean; currentStatus: string };
|
||||
mediumPolling?: { isPolling: boolean; currentStatus: string };
|
||||
hasContent?: boolean;
|
||||
flowAnalysisCompleted?: boolean;
|
||||
contentConfirmed?: boolean;
|
||||
seoAnalysis?: any;
|
||||
seoMetadata?: any;
|
||||
seoRecommendationsApplied?: boolean;
|
||||
}
|
||||
|
||||
export const useSuggestions = ({
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed = false,
|
||||
researchPolling,
|
||||
outlinePolling,
|
||||
mediumPolling,
|
||||
hasContent = false,
|
||||
flowAnalysisCompleted = false,
|
||||
contentConfirmed = false,
|
||||
seoAnalysis = null,
|
||||
seoMetadata = null,
|
||||
seoRecommendationsApplied = false
|
||||
}: SuggestionContext) => {
|
||||
return useMemo(() => {
|
||||
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
|
||||
|
||||
@@ -66,14 +84,14 @@ export const useSuggestions = (
|
||||
if (!research) {
|
||||
items.push({
|
||||
title: '🔎 Start Research',
|
||||
message: "I want to research a topic for my blog",
|
||||
message: "showResearchForm",
|
||||
priority: 'high'
|
||||
});
|
||||
} else if (research && outline.length === 0) {
|
||||
// Research completed, guide user to outline creation
|
||||
items.push({
|
||||
title: 'Next: Create Outline',
|
||||
message: 'Let\'s proceed to create an outline based on the research results',
|
||||
message: 'Research is complete. Please generate the blog outline now using the existing research data. Use the generateOutline action immediately without asking for additional information.',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
@@ -82,13 +100,13 @@ export const useSuggestions = (
|
||||
});
|
||||
items.push({
|
||||
title: '🎨 Create Custom Outline',
|
||||
message: 'I want to create an outline with my own specific instructions and requirements'
|
||||
message: 'I want to create an outline with my own specific instructions and requirements. Please ask me for my custom requirements.'
|
||||
});
|
||||
} 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 confirm the outline and am ready to generate content',
|
||||
message: 'The outline is ready. Confirm the current outline and begin content generation now. Call confirmOutlineAndGenerateContent immediately and do not ask for extra confirmation.',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
@@ -106,12 +124,6 @@ export const useSuggestions = (
|
||||
} else if (outline.length > 0 && outlineConfirmed) {
|
||||
// Outline confirmed, focus on content generation and optimization
|
||||
if (hasContent && !contentConfirmed) {
|
||||
// User has content but hasn't confirmed it yet - show content review suggestions
|
||||
items.push({
|
||||
title: 'Next: Confirm Blog Content',
|
||||
message: 'I have reviewed and confirmed my blog content is ready for the next stage',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '🔄 ReWrite Blog',
|
||||
message: 'I want to rewrite my blog with different approach, tone, or focus'
|
||||
@@ -121,24 +133,78 @@ export const useSuggestions = (
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
items.push({
|
||||
title: '📈 Run SEO Analysis',
|
||||
message: 'Analyze SEO for my blog post'
|
||||
title: 'Next: Run SEO Analysis',
|
||||
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
|
||||
});
|
||||
} else if (hasContent && contentConfirmed) {
|
||||
// Content confirmed - move to SEO stage
|
||||
items.push({
|
||||
title: '📈 Run SEO Analysis',
|
||||
message: 'Analyze SEO for my blog post',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '🧾 Generate SEO Metadata',
|
||||
message: 'Generate SEO metadata and title'
|
||||
});
|
||||
items.push({
|
||||
title: '🚀 Publish to WordPress',
|
||||
message: 'Publish my blog to WordPress'
|
||||
});
|
||||
if (!seoAnalysis) {
|
||||
// Prompt to run SEO analysis first
|
||||
items.push({
|
||||
title: 'Next: Run SEO Analysis',
|
||||
message: 'The blog content is confirmed. Execute analyzeSEO immediately to launch the SEO analysis modal without further prompts.',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
} else if (seoAnalysis && !seoRecommendationsApplied) {
|
||||
// SEO analysis exists but recommendations not applied yet
|
||||
items.push({
|
||||
title: 'Next: Apply SEO Recommendations',
|
||||
message: 'Open the SEO analysis modal and apply the actionable recommendations right away. Call analyzeSEO to reopen the modal without extra questions.',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Run analyzeContentQuality to review narrative flow and get final improvement suggestions before publishing.'
|
||||
});
|
||||
items.push({
|
||||
title: '📈 Review SEO Analysis',
|
||||
message: 'Show me the latest SEO analysis results again by running analyzeSEO.'
|
||||
});
|
||||
} else if (seoAnalysis && seoRecommendationsApplied) {
|
||||
// SEO analysis exists and recommendations applied - show next steps
|
||||
if (!seoMetadata) {
|
||||
items.push({
|
||||
title: 'Next: Generate SEO Metadata',
|
||||
message: 'SEO recommendations are applied. Execute generateSEOMetadata immediately so we can prepare titles, descriptions, and schema without further prompts.',
|
||||
priority: 'high'
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
title: 'Next: Publish',
|
||||
message: 'The blog is SEO-optimized. Use publishToPlatform with your preferred destination (wix|wordpress) right away—no additional confirmation needed.',
|
||||
priority: 'high'
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Run analyzeContentQuality to validate flow, consistency, and progression before publishing.'
|
||||
});
|
||||
items.push({
|
||||
title: 'Publish',
|
||||
message: seoMetadata
|
||||
? 'Publish my blog to your preferred platform using publishToPlatform.'
|
||||
: 'Generate SEO metadata first, then publish your blog.'
|
||||
});
|
||||
|
||||
if (seoMetadata) {
|
||||
items.push({
|
||||
title: '🚀 Publish to Wix',
|
||||
message: 'Publish my blog to Wix using publishToPlatform with platform "wix".'
|
||||
});
|
||||
items.push({
|
||||
title: '🌐 Publish to WordPress',
|
||||
message: 'Publish my blog to WordPress using publishToPlatform with platform "wordpress".'
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No content yet, show generation option
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
@@ -146,11 +212,24 @@ export const useSuggestions = (
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [research, outline, outlineConfirmed, researchPolling, outlinePolling, mediumPolling, hasContent, flowAnalysisCompleted, contentConfirmed]);
|
||||
}, [
|
||||
research,
|
||||
outline,
|
||||
outlineConfirmed,
|
||||
researchPolling,
|
||||
outlinePolling,
|
||||
mediumPolling,
|
||||
hasContent,
|
||||
flowAnalysisCompleted,
|
||||
contentConfirmed,
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
seoRecommendationsApplied
|
||||
]);
|
||||
};
|
||||
|
||||
export const SuggestionsGenerator: React.FC<SuggestionsGeneratorProps> = ({ research, outline, outlineConfirmed = false }) => {
|
||||
useSuggestions(research, outline, outlineConfirmed);
|
||||
useSuggestions({ research, outline, outlineConfirmed });
|
||||
return null; // This is just a utility component
|
||||
};
|
||||
|
||||
|
||||
@@ -70,8 +70,21 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
// Handle text replacement in the textarea
|
||||
if (contentRef.current) {
|
||||
const textarea = contentRef.current;
|
||||
const currentContent = textarea.value;
|
||||
const updatedContent = currentContent.replace(originalText, newText);
|
||||
|
||||
// For smart suggestions, newText is already the complete updated content with insertion
|
||||
// For other edits (like text selection improvements), we need to replace originalText with newText
|
||||
let updatedContent: string;
|
||||
|
||||
if (editType === 'smart-suggestion') {
|
||||
// newText already contains the full content with suggestion inserted
|
||||
updatedContent = newText;
|
||||
} else {
|
||||
// For other edits, replace the selected text
|
||||
const currentContent = textarea.value;
|
||||
updatedContent = currentContent.replace(originalText, newText);
|
||||
}
|
||||
|
||||
console.log('🔍 [BlogSection] Text updated, editType:', editType, 'New length:', updatedContent.length);
|
||||
setContent(updatedContent);
|
||||
|
||||
// Update parent state
|
||||
@@ -79,14 +92,8 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
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);
|
||||
// Note: Cursor positioning is handled by SmartTypingAssist for smart-suggestion edits
|
||||
// For other edits, we may need to handle cursor positioning here if needed
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
||||
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
||||
import TextSelectionMenu from './TextSelectionMenu';
|
||||
import useSmartTypingAssist from './SmartTypingAssist';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface BlogTextSelectionHandlerProps {
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||
@@ -281,12 +282,15 @@ const useBlogTextSelectionHandler = (
|
||||
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
|
||||
allSuggestions={smartTypingAssist.allSuggestions}
|
||||
suggestionIndex={smartTypingAssist.suggestionIndex}
|
||||
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
|
||||
onCheckFacts={handleCheckFacts}
|
||||
onCloseFactCheckResults={handleCloseFactCheckResults}
|
||||
onQuickEdit={handleQuickEdit}
|
||||
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
|
||||
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
|
||||
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
|
||||
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
|
||||
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { debug } from '../../../utils/debug';
|
||||
|
||||
interface SmartTypingAssistProps {
|
||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||
@@ -40,7 +41,9 @@ const useSmartTypingAssist = (
|
||||
const [allSuggestions, setAllSuggestions] = useState<Suggestion[]>([]);
|
||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
|
||||
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
|
||||
const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastGeneratedAtRef = useRef<number>(0);
|
||||
|
||||
// Quality improvement tracking
|
||||
const [suggestionStats, setSuggestionStats] = useState({
|
||||
@@ -52,25 +55,25 @@ const useSmartTypingAssist = (
|
||||
|
||||
// Smart typing assist functionality
|
||||
const generateSmartSuggestion = async (currentText: string) => {
|
||||
console.log('🔍 [SmartTypingAssist] generateSmartSuggestion called with text length:', currentText.length);
|
||||
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length });
|
||||
|
||||
if (currentText.length < 20) {
|
||||
console.log('🔍 [SmartTypingAssist] Text too short for suggestion');
|
||||
debug.log('[SmartTypingAssist] Text too short for suggestion');
|
||||
return; // Only suggest after some meaningful content
|
||||
}
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Starting suggestion generation...');
|
||||
debug.log('[SmartTypingAssist] Starting suggestion generation...');
|
||||
setIsGeneratingSuggestion(true);
|
||||
|
||||
try {
|
||||
// Import the assistive writing API
|
||||
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Calling assistive writing API...');
|
||||
debug.log('[SmartTypingAssist] Calling assistive writing API...');
|
||||
const response = await assistiveWritingApi.getSuggestion(currentText, 3); // Get 3 suggestions
|
||||
|
||||
if (response.success && response.suggestions.length > 0) {
|
||||
console.log('🔍 [SmartTypingAssist] Received', response.suggestions.length, 'suggestions from API');
|
||||
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
|
||||
|
||||
// Store all suggestions
|
||||
setAllSuggestions(response.suggestions);
|
||||
@@ -78,7 +81,7 @@ const useSmartTypingAssist = (
|
||||
|
||||
// Show first suggestion
|
||||
const firstSuggestion = response.suggestions[0];
|
||||
console.log('🔍 [SmartTypingAssist] Showing first suggestion:', firstSuggestion.text.substring(0, 50) + '...');
|
||||
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
|
||||
|
||||
// Track suggestion shown
|
||||
setSuggestionStats(prev => ({
|
||||
@@ -86,12 +89,30 @@ const useSmartTypingAssist = (
|
||||
totalShown: prev.totalShown + 1
|
||||
}));
|
||||
|
||||
// Get cursor position for suggestion placement
|
||||
// Get viewport-safe position for suggestion placement
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - 420)); // Ensure it stays on screen
|
||||
const y = Math.max(20, rect.bottom + 10);
|
||||
const maxWidth = 420;
|
||||
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
|
||||
|
||||
// Try to position below the editor
|
||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||
let y = rect.bottom + 10;
|
||||
|
||||
// If it would be cut off at the bottom, position above instead
|
||||
if (y + maxHeight > window.innerHeight - 20) {
|
||||
y = rect.top - maxHeight - 10;
|
||||
// If it would be cut off at the top, position in viewport center
|
||||
if (y < 20) {
|
||||
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
||||
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure it's never cut off
|
||||
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
||||
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
||||
|
||||
setSmartSuggestion({
|
||||
text: firstSuggestion.text,
|
||||
@@ -101,7 +122,7 @@ const useSmartTypingAssist = (
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('🔍 [SmartTypingAssist] No suggestions received from API');
|
||||
debug.log('[SmartTypingAssist] No suggestions received from API');
|
||||
// Fallback to generic suggestions if API fails
|
||||
const fallbackSuggestions = [
|
||||
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
|
||||
@@ -116,8 +137,26 @@ const useSmartTypingAssist = (
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + 20;
|
||||
const y = rect.bottom + 5;
|
||||
const maxWidth = 420;
|
||||
const maxHeight = 350; // Increased to accommodate full suggestion with buttons
|
||||
|
||||
// Try to position below the editor
|
||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||
let y = rect.bottom + 10;
|
||||
|
||||
// If it would be cut off at the bottom, position above instead
|
||||
if (y + maxHeight > window.innerHeight - 20) {
|
||||
y = rect.top - maxHeight - 10;
|
||||
// If it would be cut off at the top, position in viewport center
|
||||
if (y < 20) {
|
||||
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
||||
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure it's never cut off
|
||||
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
||||
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
||||
|
||||
setSmartSuggestion({
|
||||
text: randomSuggestion,
|
||||
@@ -126,7 +165,7 @@ const useSmartTypingAssist = (
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('🔍 [SmartTypingAssist] Failed to generate smart suggestion:', error);
|
||||
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
|
||||
|
||||
// Fallback to generic suggestions on error
|
||||
const fallbackSuggestions = [
|
||||
@@ -142,8 +181,14 @@ const useSmartTypingAssist = (
|
||||
if (contentRef.current) {
|
||||
const element = contentRef.current;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = rect.left + 20;
|
||||
const y = rect.bottom + 5;
|
||||
const maxWidth = 420;
|
||||
const maxHeight = 160;
|
||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||
let y = rect.bottom + 5;
|
||||
if (y > window.innerHeight - maxHeight) {
|
||||
y = window.innerHeight - (maxHeight + 20);
|
||||
x = Math.max(20, window.innerWidth - (maxWidth + 20));
|
||||
}
|
||||
|
||||
setSmartSuggestion({
|
||||
text: randomSuggestion,
|
||||
@@ -156,7 +201,7 @@ const useSmartTypingAssist = (
|
||||
};
|
||||
|
||||
const handleTypingChange = (newText: string) => {
|
||||
console.log('🔍 [SmartTypingAssist] handleTypingChange called with text length:', newText.length);
|
||||
// Not logging this as it fires on every keystroke - too noisy
|
||||
|
||||
// Clear existing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
@@ -168,29 +213,45 @@ const useSmartTypingAssist = (
|
||||
|
||||
// Set new timeout for suggestion generation
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
console.log('🔍 [SmartTypingAssist] Typing timeout triggered, text length:', newText.length, 'hasShownFirstSuggestion:', hasShownFirstSuggestion);
|
||||
debug.log('[SmartTypingAssist] Typing timeout triggered', { textLength: newText.length, hasShownFirst: hasShownFirstSuggestion });
|
||||
|
||||
// First time suggestion appears automatically
|
||||
if (!hasShownFirstSuggestion && newText.length > 20) {
|
||||
console.log('🔍 [SmartTypingAssist] Generating first suggestion');
|
||||
const cooldownMs = 15000; // 15s cooldown between suggestions
|
||||
const now = Date.now();
|
||||
const sinceLast = now - lastGeneratedAtRef.current;
|
||||
|
||||
// First time suggestion appears automatically with sufficient content
|
||||
if (!hasShownFirstSuggestion && newText.length > 50 && !isGeneratingSuggestion) {
|
||||
debug.log('[SmartTypingAssist] Generating first suggestion');
|
||||
generateSmartSuggestion(newText);
|
||||
setHasShownFirstSuggestion(true);
|
||||
lastGeneratedAtRef.current = now;
|
||||
}
|
||||
// After first time, only suggest after longer pauses or more content
|
||||
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
|
||||
console.log('🔍 [SmartTypingAssist] Generating subsequent suggestion');
|
||||
generateSmartSuggestion(newText);
|
||||
} else {
|
||||
console.log('🔍 [SmartTypingAssist] No suggestion generated - conditions not met');
|
||||
// After first time, show "Continue writing" prompt instead of random suggestions
|
||||
else if (hasShownFirstSuggestion && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingSuggestion && !smartSuggestion) {
|
||||
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
|
||||
setShowContinueWritingPrompt(true);
|
||||
}
|
||||
// Removed verbose log about skipping prompts as it's too noisy
|
||||
}, 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;
|
||||
const element = contentRef.current as HTMLTextAreaElement;
|
||||
const currentContent = element.value || '';
|
||||
|
||||
// Get cursor position
|
||||
const cursorPosition = element.selectionStart || currentContent.length;
|
||||
debug.log('[SmartTypingAssist] Cursor position', { cursorPosition, contentLength: currentContent.length });
|
||||
|
||||
// Insert suggestion at cursor position
|
||||
const beforeCursor = currentContent.substring(0, cursorPosition);
|
||||
const afterCursor = currentContent.substring(cursorPosition);
|
||||
const suggestionWithSpace = ' ' + smartSuggestion.text + ' ';
|
||||
const newContent = beforeCursor + suggestionWithSpace + afterCursor;
|
||||
|
||||
// Calculate where cursor should be after insertion (right after the suggestion)
|
||||
const newCursorPosition = cursorPosition + suggestionWithSpace.length;
|
||||
|
||||
// Track suggestion accepted
|
||||
setSuggestionStats(prev => ({
|
||||
@@ -198,14 +259,21 @@ const useSmartTypingAssist = (
|
||||
totalAccepted: prev.totalAccepted + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion accepted! Stats:', {
|
||||
...suggestionStats,
|
||||
totalAccepted: suggestionStats.totalAccepted + 1
|
||||
});
|
||||
debug.log('[SmartTypingAssist] Suggestion accepted', { cursorPosition, newContentLength: newContent.length, newCursorPosition });
|
||||
|
||||
// Use the text replacement callback
|
||||
onTextReplace(currentContent, newContent, 'smart-suggestion');
|
||||
|
||||
// Set cursor position after the inserted text
|
||||
setTimeout(() => {
|
||||
if (contentRef.current) {
|
||||
const el = contentRef.current as HTMLTextAreaElement;
|
||||
el.focus();
|
||||
el.setSelectionRange(newCursorPosition, newCursorPosition);
|
||||
debug.log('[SmartTypingAssist] Cursor positioned', { position: newCursorPosition });
|
||||
}
|
||||
}, 50);
|
||||
|
||||
setSmartSuggestion(null);
|
||||
}
|
||||
};
|
||||
@@ -217,10 +285,7 @@ const useSmartTypingAssist = (
|
||||
totalRejected: prev.totalRejected + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion rejected! Stats:', {
|
||||
...suggestionStats,
|
||||
totalRejected: suggestionStats.totalRejected + 1
|
||||
});
|
||||
debug.log('[SmartTypingAssist] Suggestion rejected', { stats: { ...suggestionStats, totalRejected: suggestionStats.totalRejected + 1 } });
|
||||
|
||||
setSmartSuggestion(null);
|
||||
setAllSuggestions([]);
|
||||
@@ -238,11 +303,8 @@ const useSmartTypingAssist = (
|
||||
totalCycled: prev.totalCycled + 1
|
||||
}));
|
||||
|
||||
console.log('🔍 [SmartTypingAssist] Showing next suggestion:', nextIndex + 1, 'of', allSuggestions.length);
|
||||
console.log('🔍 [SmartTypingAssist] Suggestion cycled! Stats:', {
|
||||
...suggestionStats,
|
||||
totalCycled: suggestionStats.totalCycled + 1
|
||||
});
|
||||
debug.log('[SmartTypingAssist] Showing next suggestion', { index: nextIndex + 1, total: allSuggestions.length });
|
||||
debug.log('[SmartTypingAssist] Suggestion cycled', { stats: { ...suggestionStats, totalCycled: suggestionStats.totalCycled + 1 } });
|
||||
|
||||
setSuggestionIndex(nextIndex);
|
||||
setSmartSuggestion(prev => prev ? {
|
||||
@@ -254,6 +316,25 @@ const useSmartTypingAssist = (
|
||||
}
|
||||
};
|
||||
|
||||
// Handle "Continue writing" button click
|
||||
const handleRequestSuggestion = async () => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
const element = contentRef.current as HTMLTextAreaElement;
|
||||
const currentContent = element.value || '';
|
||||
|
||||
setShowContinueWritingPrompt(false);
|
||||
|
||||
if (currentContent.length > 20) {
|
||||
await generateSmartSuggestion(currentContent);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle dismissing the "Continue writing" prompt
|
||||
const handleDismissPrompt = () => {
|
||||
setShowContinueWritingPrompt(false);
|
||||
};
|
||||
|
||||
// Get suggestion statistics for quality improvement
|
||||
const getSuggestionStats = () => {
|
||||
const acceptanceRate = suggestionStats.totalShown > 0
|
||||
@@ -284,10 +365,13 @@ const useSmartTypingAssist = (
|
||||
allSuggestions,
|
||||
suggestionIndex,
|
||||
suggestionStats: getSuggestionStats(),
|
||||
showContinueWritingPrompt,
|
||||
handleTypingChange,
|
||||
handleAcceptSuggestion,
|
||||
handleRejectSuggestion,
|
||||
handleNextSuggestion,
|
||||
handleRequestSuggestion,
|
||||
handleDismissPrompt,
|
||||
getSuggestionStats,
|
||||
generateSmartSuggestion
|
||||
};
|
||||
|
||||
@@ -34,12 +34,15 @@ interface TextSelectionMenuProps {
|
||||
}>;
|
||||
}>;
|
||||
suggestionIndex: number;
|
||||
showContinueWritingPrompt: boolean;
|
||||
onCheckFacts: (text: string) => void;
|
||||
onCloseFactCheckResults: () => void;
|
||||
onQuickEdit: (editType: string, selectedText: string) => void;
|
||||
onAcceptSuggestion: () => void;
|
||||
onRejectSuggestion: () => void;
|
||||
onNextSuggestion: () => void;
|
||||
onRequestSuggestion: () => void;
|
||||
onDismissPrompt: () => void;
|
||||
}
|
||||
|
||||
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
@@ -51,12 +54,15 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
isGeneratingSuggestion,
|
||||
allSuggestions,
|
||||
suggestionIndex,
|
||||
showContinueWritingPrompt,
|
||||
onCheckFacts,
|
||||
onCloseFactCheckResults,
|
||||
onQuickEdit,
|
||||
onAcceptSuggestion,
|
||||
onRejectSuggestion,
|
||||
onNextSuggestion
|
||||
onNextSuggestion,
|
||||
onRequestSuggestion,
|
||||
onDismissPrompt
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -387,8 +393,10 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
zIndex: 10002,
|
||||
maxWidth: '400px',
|
||||
maxWidth: '420px',
|
||||
minWidth: '320px',
|
||||
maxHeight: '350px',
|
||||
overflow: 'auto',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
@@ -540,6 +548,93 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Continue Writing Prompt */}
|
||||
{showContinueWritingPrompt && !isGeneratingSuggestion && !smartSuggestion && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
right: '20px',
|
||||
background: 'rgba(59, 130, 246, 0.95)',
|
||||
color: 'white',
|
||||
padding: '16px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.25)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
zIndex: 10001,
|
||||
minWidth: '280px',
|
||||
maxWidth: '360px'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}>
|
||||
✨ AI Writing Assistant
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
opacity: 0.9,
|
||||
marginBottom: '16px',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
ALwrity can contextually continue writing your blog. Click below to get AI-powered suggestions.
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<button
|
||||
onClick={onRequestSuggestion}
|
||||
style={{
|
||||
background: '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: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flex: 1
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
>
|
||||
✍️ Continue Writing
|
||||
</button>
|
||||
<button
|
||||
onClick={onDismissPrompt}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 16px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
|
||||
297
frontend/src/components/ImageGen/ImageGenerator.tsx
Normal file
297
frontend/src/components/ImageGen/ImageGenerator.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useMemo, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Box, Button, MenuItem, Select, TextField, Typography, FormControl, InputLabel, Grid, Card, CardMedia, CircularProgress, LinearProgress, Collapse, IconButton, Tabs, Tab, Tooltip } from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { useImageGeneration, ImageGenerationRequest, fetchPromptSuggestions } from './useImageGeneration';
|
||||
|
||||
type Provider = 'gemini' | 'huggingface' | 'stability';
|
||||
|
||||
interface ImageGeneratorProps {
|
||||
defaultProvider?: Provider;
|
||||
defaultModel?: string;
|
||||
defaultPrompt?: string;
|
||||
onImageReady?: (base64: string) => void;
|
||||
// Optional context to build SME, provider-tailored prompts
|
||||
context?: {
|
||||
title?: string | null;
|
||||
outline?: any[];
|
||||
research?: any;
|
||||
persona?: { audience?: string; tone?: string; industry?: string } | any;
|
||||
section?: {
|
||||
heading?: string;
|
||||
subheadings?: string[];
|
||||
key_points?: string[];
|
||||
keywords?: string[];
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageGeneratorHandle {
|
||||
suggest: () => Promise<void> | void;
|
||||
generate: () => Promise<void> | void;
|
||||
openAdvanced: () => void;
|
||||
closeAdvanced: () => void;
|
||||
}
|
||||
|
||||
export const ImageGenerator = React.forwardRef<ImageGeneratorHandle, ImageGeneratorProps>((
|
||||
{ defaultProvider, defaultModel, defaultPrompt, onImageReady, context },
|
||||
ref
|
||||
) => {
|
||||
const [provider, setProvider] = useState<Provider>(defaultProvider || (process.env.NEXT_PUBLIC_GPT_PROVIDER as Provider) || 'huggingface');
|
||||
const [model, setModel] = useState<string>(defaultModel || 'black-forest-labs/FLUX.1-Krea-dev');
|
||||
const [prompt, setPrompt] = useState<string>(defaultPrompt || '');
|
||||
const [negative, setNegative] = useState<string>('');
|
||||
const [width, setWidth] = useState<number>(1024);
|
||||
const [height, setHeight] = useState<number>(1024);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const { isGenerating, error, result, generate } = useImageGeneration();
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<Array<{ prompt: string; negative_prompt?: string; width?: number; height?: number; overlay_text?: string }>>([]);
|
||||
const [suggestionIndex, setSuggestionIndex] = useState<number>(0);
|
||||
|
||||
const canGenerate = useMemo(() => prompt.trim().length > 0 && !isGenerating, [prompt, isGenerating]);
|
||||
|
||||
// High-contrast input styling for readability on light backgrounds
|
||||
const textInputSx = {
|
||||
'& .MuiInputBase-input': { color: '#202124' },
|
||||
'& .MuiInputLabel-root': { color: '#5f6368' },
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: '#cbd5e1' },
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: '#94a3b8' },
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: '#1976d2' },
|
||||
backgroundColor: '#ffffff'
|
||||
} as const;
|
||||
|
||||
// Default negative prompts by provider for blog writer use-case
|
||||
useEffect(() => {
|
||||
if (negative.trim().length > 0) return;
|
||||
if (provider === 'huggingface') {
|
||||
setNegative('blurry, distorted, cartoon, low quality, bad anatomy, extra limbs, watermark, brand logos, text artifacts, oversaturated, noisy, jpeg artifacts');
|
||||
} else if (provider === 'gemini') {
|
||||
setNegative('cartoon, clip-art, abstract, noisy, low resolution, artifacts, watermark, brand logos, text artifacts');
|
||||
} else {
|
||||
setNegative('blurry, distorted, low quality, bad anatomy, extra limbs, watermark, brand logos, jpeg artifacts, oversharpened, text artifacts');
|
||||
}
|
||||
// run once on mount (and when provider changes if negative is empty)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider]);
|
||||
|
||||
// Auto-suggest on open for better defaults (only if no initial prompt)
|
||||
useEffect(() => {
|
||||
if (!prompt || prompt.trim().length === 0) {
|
||||
// fire and forget; UI shows spinner on the button if user clicks again
|
||||
suggestPrompt().catch(() => {});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Provider-specialized prompt suggestions using backend structured response; fallback locally
|
||||
const suggestPrompt = async () => {
|
||||
setLoadingSuggestions(true);
|
||||
try {
|
||||
const payload = {
|
||||
provider,
|
||||
title: context?.title || context?.section?.heading || defaultPrompt || '',
|
||||
section: context?.section || undefined,
|
||||
research: context?.research || undefined,
|
||||
persona: context?.persona || undefined,
|
||||
};
|
||||
const suggs = await fetchPromptSuggestions(payload);
|
||||
setSuggestions(suggs);
|
||||
if (suggs.length > 0) {
|
||||
setPrompt(suggs[0].prompt || '');
|
||||
if (suggs[0].negative_prompt) setNegative(suggs[0].negative_prompt);
|
||||
if (suggs[0].width) setWidth(suggs[0].width);
|
||||
if (suggs[0].height) setHeight(suggs[0].height);
|
||||
setSuggestionIndex(0);
|
||||
}
|
||||
} catch (e) {
|
||||
// fallback to local heuristic
|
||||
const title = (context?.section?.heading || context?.title || '').trim();
|
||||
const subheads: string[] = context?.section?.subheadings || [];
|
||||
const keyPoints: string[] = context?.section?.key_points || [];
|
||||
const keywords: string[] = Array.isArray(context?.section?.keywords)
|
||||
? context?.section?.keywords
|
||||
: (Array.isArray(context?.research?.keywords?.primary_keywords)
|
||||
? context?.research?.keywords?.primary_keywords
|
||||
: (context?.research?.keywords?.primary || []));
|
||||
const primary = keywords?.slice(0, 5).filter(Boolean).join(', ');
|
||||
const audience = context?.persona?.audience || 'content creators and digital marketers';
|
||||
const industry = context?.persona?.industry || context?.research?.domain || 'your industry';
|
||||
const tone = context?.persona?.tone || 'professional, trustworthy';
|
||||
const narrativeHints = [
|
||||
subheads?.length ? `Subheadings: ${subheads.slice(0,3).join(' | ')}` : null,
|
||||
keyPoints?.length ? `Key points: ${keyPoints.slice(0,3).join(' | ')}` : null,
|
||||
].filter(Boolean).join('. ');
|
||||
setPrompt(`${title} — ${narrativeHints}. Emphasis: ${primary}. Audience: ${audience}. Industry: ${industry}. Tone: ${tone}.`);
|
||||
} finally {
|
||||
setLoadingSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onGenerate = async () => {
|
||||
const req: ImageGenerationRequest = { prompt, negative_prompt: negative, provider, model, width, height };
|
||||
const res = await generate(req);
|
||||
if (res && onImageReady) onImageReady(res.image_base64);
|
||||
// publish to image bus for downstream consumers (e.g., SEO metadata modal)
|
||||
try {
|
||||
const { publishImage } = await import('../../utils/imageBus');
|
||||
publishImage({ base64: res.image_base64, provider: res.provider, model: res.model });
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
suggest: () => suggestPrompt(),
|
||||
generate: () => onGenerate(),
|
||||
openAdvanced: () => setShowAdvanced(v => !v),
|
||||
closeAdvanced: () => setShowAdvanced(false)
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#202124' }}>Generate Blog Section Image</Typography>
|
||||
|
||||
{/* Advanced Options in Header Area */}
|
||||
<Collapse in={showAdvanced}>
|
||||
<Box sx={{ mb: 2, border: '1px solid #e0e0e0', borderRadius: 1, p: 1.5, backgroundColor: '#fafafa', color: '#202124' }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Tooltip title="Select the AI image generation provider. Hugging Face offers photorealistic Flux models, Gemini provides brand-safe editorial images, and Stability AI delivers SDXL-quality professional outputs." placement="top" arrow>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select value={provider} label="Provider" onChange={(e) => setProvider(e.target.value as Provider)} sx={textInputSx} MenuProps={{ PaperProps: { sx: { color: '#202124' } } }}>
|
||||
<MenuItem value="huggingface">Hugging Face</MenuItem>
|
||||
<MenuItem value="gemini">Gemini</MenuItem>
|
||||
<MenuItem value="stability">Stability</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={5}>
|
||||
<Tooltip title="Specify the exact model to use. Leave empty to use the provider's default. For Hugging Face, the default is FLUX.1-Krea-dev, optimized for photorealistic blog images." placement="top" arrow>
|
||||
<TextField fullWidth label="Model" value={model} onChange={(e) => setModel(e.target.value)} helperText={provider === 'huggingface' ? 'Default: black-forest-labs/FLUX.1-Krea-dev' : 'Leave empty to use provider default'} sx={textInputSx} />
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={2}>
|
||||
<Tooltip title="Image width in pixels. Recommended: 1024 for square images, 1920 for landscape covers. Higher values increase quality but take longer to generate." placement="top" arrow>
|
||||
<TextField fullWidth type="number" label="Width" value={width} onChange={(e) => setWidth(parseInt(e.target.value || '0', 10))} sx={textInputSx} />
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={2}>
|
||||
<Tooltip title="Image height in pixels. Recommended: 1024 for square images, 1080 for portrait covers. Aspect ratio affects composition and visual appeal." placement="top" arrow>
|
||||
<TextField fullWidth type="number" label="Height" value={height} onChange={(e) => setHeight(parseInt(e.target.value || '0', 10))} sx={textInputSx} />
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* Loading indicators */}
|
||||
{loadingSuggestions && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Loading suggestions...</Typography>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
)}
|
||||
{isGenerating && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" sx={{ mb: 0.5, display: 'block' }}>Generating image...</Typography>
|
||||
<LinearProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Prompt and Negative Prompt Side by Side - 80/20 split, stack on mobile */}
|
||||
<Box sx={{ mb: 2, display: { xs: 'block', md: 'flex' }, gap: 2 }}>
|
||||
<Tooltip title="Describe what you want in the image. Be specific: mention style (photorealistic, editorial, cinematic), subjects, composition, lighting, and mood. The AI uses this to generate your image. Tips: Include camera settings (e.g., '50mm lens, f/2.8'), lighting direction, and visual emphasis." placement="top" arrow>
|
||||
<TextField
|
||||
sx={{ flex: { md: '0 0 80%' }, width: { xs: '100%' }, mb: { xs: 2, md: 0 } }}
|
||||
InputProps={{ sx: { color: '#202124' } }}
|
||||
InputLabelProps={{ sx: { color: '#5f6368' } }}
|
||||
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||
multiline
|
||||
minRows={3}
|
||||
label="Prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Describe the image..."
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="List elements you want to avoid in the image (e.g., blurry, cartoon, watermark, low quality). This helps the AI exclude unwanted features. Common items: text artifacts, brand logos, distorted anatomy, oversaturation, noise." placement="top" arrow>
|
||||
<TextField
|
||||
sx={{ flex: { md: '0 0 20%' }, width: { xs: '100%' } }}
|
||||
InputProps={{ sx: { color: '#202124' } }}
|
||||
InputLabelProps={{ sx: { color: '#5f6368' } }}
|
||||
multiline
|
||||
minRows={3}
|
||||
label="Negative Prompt (optional)"
|
||||
value={negative}
|
||||
onChange={(e) => setNegative(e.target.value)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Tooltip title="Get AI-generated prompt suggestions tailored to your blog section. Uses your section title, subheadings, key points, keywords, and research data to create hyper-personalized prompts optimized for your chosen provider. Click to see multiple suggestions in tabs." placement="top" arrow>
|
||||
<span>
|
||||
<Button sx={{ mr: 1 }} variant="outlined" onClick={suggestPrompt} disabled={loadingSuggestions}>{loadingSuggestions ? 'Suggesting…' : 'Suggest prompt'}</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Generate the image using your current prompt and settings. The process may take 10-30 seconds depending on provider and image size. Once generated, the image will appear below and can be used for your blog section." placement="top" arrow>
|
||||
<span>
|
||||
<Button variant="contained" disabled={!canGenerate} onClick={onGenerate} startIcon={isGenerating ? <CircularProgress size={18} /> : undefined}>
|
||||
{isGenerating ? 'Generating…' : 'Generate Image'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
{error && (
|
||||
<Grid item xs={12}>
|
||||
<Typography color="error" variant="body2">{error}</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
{result && (
|
||||
<Grid item xs={12}>
|
||||
<Card sx={{ maxWidth: 512 }}>
|
||||
<CardMedia component="img" image={`data:image/png;base64,${result.image_base64}`} alt="generated" />
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
{suggestions.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Tooltip title="Browse through AI-generated prompt suggestions. Each tab shows a different prompt optimized for your section and provider. Click a tab to preview and auto-fill the prompt fields. You can then modify or use it directly." placement="top" arrow>
|
||||
<div>
|
||||
<Tabs value={suggestionIndex} onChange={(e, v) => {
|
||||
setSuggestionIndex(v);
|
||||
const s = suggestions[v];
|
||||
if (s) {
|
||||
setPrompt(s.prompt || '');
|
||||
setNegative(s.negative_prompt || '');
|
||||
if (s.width) setWidth(s.width);
|
||||
if (s.height) setHeight(s.height);
|
||||
}
|
||||
}} variant="scrollable" scrollButtons allowScrollButtonsMobile>
|
||||
{suggestions.map((_, i) => (
|
||||
<Tab key={i} label={`Prompt ${i + 1}`} />
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip title="Preview of the currently selected prompt suggestion. Shows the main prompt and negative prompt (if any). This preview updates when you click different tabs above." placement="top" arrow>
|
||||
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderTop: 'none', borderRadius: '0 0 8px 8px', background: '#fff' }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>Preview</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', color: '#202124' }}>{suggestions[suggestionIndex]?.prompt}</Typography>
|
||||
{suggestions[suggestionIndex]?.negative_prompt && (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 1 }}>Negative: {suggestions[suggestionIndex]?.negative_prompt}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default ImageGenerator;
|
||||
109
frontend/src/components/ImageGen/ImageGeneratorModal.tsx
Normal file
109
frontend/src/components/ImageGen/ImageGeneratorModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import ImageGenerator, { ImageGeneratorHandle } from './ImageGenerator';
|
||||
|
||||
interface ImageGeneratorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
defaultPrompt?: string;
|
||||
context?: any;
|
||||
onImageGenerated?: (imageBase64: string, sectionId?: string) => void;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 2000,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch'
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff',
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
margin: '24px',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
padding: '16px 20px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color: '#202124'
|
||||
};
|
||||
|
||||
const bodyStyle: React.CSSProperties = {
|
||||
padding: 20,
|
||||
overflow: 'auto',
|
||||
flex: 1
|
||||
};
|
||||
|
||||
const ImageGeneratorModal: React.FC<ImageGeneratorModalProps> = ({ isOpen, onClose, defaultPrompt, context, onImageGenerated }) => {
|
||||
const handleImageReady = (base64: string) => {
|
||||
if (onImageGenerated) {
|
||||
onImageGenerated(base64, context?.section?.id || context?.sectionId);
|
||||
}
|
||||
};
|
||||
|
||||
const imageRef = useRef<ImageGeneratorHandle>(null);
|
||||
const sectionTitle = useMemo(() => context?.section?.heading || context?.title || 'Generate Blog Section Image', [context]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={headerStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>{sectionTitle}</h3>
|
||||
<span style={{ fontSize: 12, color: '#5f6368' }}>Generate Blog Section Image</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Tooltip title="Toggle advanced image generation settings. Opens provider selection (Hugging Face, Gemini, Stability AI), model specification, and image dimensions (width/height). Hover or click to show/hide these options." placement="bottom" arrow>
|
||||
<button
|
||||
onMouseEnter={() => imageRef.current?.openAdvanced()}
|
||||
onClick={() => {
|
||||
// toggle
|
||||
if (imageRef.current) {
|
||||
imageRef.current.openAdvanced();
|
||||
}
|
||||
}}
|
||||
style={{ border: '1px solid #cbd5e1', background: '#ffffff', color: '#334155', borderRadius: 20, padding: '6px 12px', cursor: 'pointer', boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}
|
||||
>
|
||||
Advanced Image Options
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Get AI-powered prompt suggestions tailored to your blog section. Uses section title, subheadings, key points, keywords, and research data to generate multiple hyper-personalized prompts. Suggestions appear as tabs below." placement="bottom" arrow>
|
||||
<button
|
||||
onClick={() => imageRef.current?.suggest()}
|
||||
style={{ border: '1px solid #1976d2', background: '#fff', color: '#1976d2', borderRadius: 20, padding: '6px 12px', cursor: 'pointer' }}
|
||||
>
|
||||
Suggest Prompt
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="Close the image generator modal. Any generated images are saved and will appear in your blog section." placement="bottom" arrow>
|
||||
<button onClick={onClose} style={{ border: '1px solid #ddd', background: '#f5f5f5', borderRadius: 6, padding: '6px 10px', cursor: 'pointer' }}>Close</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div style={bodyStyle}>
|
||||
<ImageGenerator ref={imageRef} defaultPrompt={defaultPrompt || ''} context={context} onImageReady={handleImageReady} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGeneratorModal;
|
||||
|
||||
|
||||
73
frontend/src/components/ImageGen/useImageGeneration.ts
Normal file
73
frontend/src/components/ImageGen/useImageGeneration.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
export interface ImageGenerationRequest {
|
||||
prompt: string;
|
||||
negative_prompt?: string;
|
||||
provider?: 'gemini' | 'huggingface' | 'stability';
|
||||
model?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
guidance_scale?: number;
|
||||
steps?: number;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface ImageGenerationResponse {
|
||||
success: boolean;
|
||||
image_base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
provider: string;
|
||||
model?: string;
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export function useImageGeneration() {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<ImageGenerationResponse | null>(null);
|
||||
|
||||
const generate = useCallback(async (req: ImageGenerationRequest) => {
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { data } = await apiClient.post<ImageGenerationResponse>('/api/images/generate', req);
|
||||
setResult(data);
|
||||
return data;
|
||||
} catch (e: any) {
|
||||
const message = e?.response?.data?.detail || e?.message || 'Image generation failed';
|
||||
setError(message);
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isGenerating, error, result, generate };
|
||||
}
|
||||
|
||||
export interface PromptSuggestion {
|
||||
prompt: string;
|
||||
negative_prompt?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
overlay_text?: string;
|
||||
}
|
||||
|
||||
export async function fetchPromptSuggestions(payload: any): Promise<PromptSuggestion[]> {
|
||||
const res = await fetch('/api/images/suggest-prompts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || 'Failed to fetch prompt suggestions');
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.suggestions || [];
|
||||
}
|
||||
|
||||
|
||||
210
frontend/src/hooks/usePhaseNavigation.ts
Normal file
210
frontend/src/hooks/usePhaseNavigation.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { BlogResearchResponse, BlogOutlineSection } from '../services/blogWriterApi';
|
||||
|
||||
export interface Phase {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
current: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const usePhaseNavigation = (
|
||||
research: BlogResearchResponse | null,
|
||||
outline: BlogOutlineSection[],
|
||||
outlineConfirmed: boolean,
|
||||
hasContent: boolean,
|
||||
contentConfirmed: boolean,
|
||||
seoAnalysis: any,
|
||||
seoMetadata: any,
|
||||
seoRecommendationsApplied?: boolean
|
||||
) => {
|
||||
// Initialize from localStorage if available
|
||||
const getInitialPhase = (): string => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem('blogwriter_current_phase');
|
||||
if (stored) return stored;
|
||||
}
|
||||
} catch {}
|
||||
return 'research';
|
||||
};
|
||||
|
||||
const [currentPhase, setCurrentPhase] = useState<string>(getInitialPhase());
|
||||
const [userSelectedPhase, setUserSelectedPhase] = useState<boolean>(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem('blogwriter_user_selected_phase');
|
||||
return stored === 'true';
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
});
|
||||
const lastClickAtRef = useRef<number>(0);
|
||||
|
||||
// Determine phase states based on current data
|
||||
const phases = useMemo((): Phase[] => {
|
||||
const researchCompleted = !!research;
|
||||
const outlineCompleted = outline.length > 0;
|
||||
const contentCompleted = hasContent && contentConfirmed;
|
||||
// SEO is complete when analysis exists AND recommendations are applied
|
||||
const seoCompleted = !!seoAnalysis && (seoRecommendationsApplied === true || !!seoMetadata);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'research',
|
||||
name: 'Research',
|
||||
icon: '🔍',
|
||||
description: 'Research your topic and gather data',
|
||||
completed: researchCompleted,
|
||||
current: currentPhase === 'research',
|
||||
disabled: false // Research is always accessible
|
||||
},
|
||||
{
|
||||
id: 'outline',
|
||||
name: 'Outline',
|
||||
icon: '📝',
|
||||
description: 'Create and refine your blog outline',
|
||||
completed: outlineCompleted,
|
||||
current: currentPhase === 'outline',
|
||||
disabled: !researchCompleted // Disabled only if research not completed (can always go back if completed)
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
name: 'Content',
|
||||
icon: '✍️',
|
||||
description: 'Generate and edit your blog content',
|
||||
completed: contentCompleted,
|
||||
current: currentPhase === 'content',
|
||||
disabled: !outlineCompleted // Disabled only if outline not completed (can always go back if completed)
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
name: 'SEO',
|
||||
icon: '📈',
|
||||
description: 'Optimize for search engines',
|
||||
completed: seoCompleted,
|
||||
current: currentPhase === 'seo',
|
||||
disabled: !contentCompleted // Disabled only if content not completed (can always go back if completed)
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
name: 'Publish',
|
||||
icon: '🚀',
|
||||
description: 'Publish your blog post',
|
||||
completed: false, // This would be set when actually published
|
||||
current: currentPhase === 'publish',
|
||||
disabled: !seoCompleted // Can access if SEO done
|
||||
}
|
||||
];
|
||||
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, currentPhase]);
|
||||
|
||||
// Persist current phase and user selection
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem('blogwriter_current_phase', currentPhase);
|
||||
window.localStorage.setItem('blogwriter_user_selected_phase', String(userSelectedPhase));
|
||||
}
|
||||
} catch {}
|
||||
}, [currentPhase, userSelectedPhase]);
|
||||
|
||||
// Validate stored phase against current availability (quiet)
|
||||
useEffect(() => {
|
||||
const current = phases.find(p => p.id === currentPhase);
|
||||
if (!current) {
|
||||
setCurrentPhase('research');
|
||||
return;
|
||||
}
|
||||
if (current.disabled) {
|
||||
// Find the first non-disabled phase in order of progression the user qualifies for
|
||||
const fallback = phases.find(p => !p.disabled) || ({ id: 'research' } as Phase);
|
||||
if (fallback.id !== currentPhase) {
|
||||
setCurrentPhase(fallback.id);
|
||||
}
|
||||
}
|
||||
}, [phases, currentPhase]);
|
||||
|
||||
// Auto-update current phase based on completion status (only if user hasn't manually selected a phase)
|
||||
useEffect(() => {
|
||||
if (userSelectedPhase) {
|
||||
return; // Don't auto-update if user has manually selected a phase
|
||||
}
|
||||
|
||||
// Auto-progress to the next available phase when conditions are met
|
||||
if (research && outline.length === 0) {
|
||||
// Research completed, but no outline yet - stay on research
|
||||
if (currentPhase !== 'research') {
|
||||
setCurrentPhase('research');
|
||||
}
|
||||
} else if (research && outline.length > 0 && !outlineConfirmed) {
|
||||
// Outline created but not confirmed - move to outline phase
|
||||
if (currentPhase !== 'outline') {
|
||||
setCurrentPhase('outline');
|
||||
}
|
||||
} else if (outlineConfirmed && hasContent && !contentConfirmed) {
|
||||
// Content generated but not confirmed - move to content phase
|
||||
if (currentPhase !== 'content') {
|
||||
setCurrentPhase('content');
|
||||
}
|
||||
} else if (contentConfirmed && !seoAnalysis) {
|
||||
// Content confirmed but no SEO analysis yet - move to SEO phase
|
||||
if (currentPhase !== 'seo') {
|
||||
setCurrentPhase('seo');
|
||||
}
|
||||
} else if (seoAnalysis && !seoRecommendationsApplied && !seoMetadata) {
|
||||
// SEO analysis done but recommendations not applied - stay on SEO phase
|
||||
if (currentPhase !== 'seo') {
|
||||
setCurrentPhase('seo');
|
||||
}
|
||||
} else if (seoAnalysis && (seoRecommendationsApplied || seoMetadata)) {
|
||||
// SEO recommendations applied or metadata generated
|
||||
if (currentPhase === 'seo') {
|
||||
// CRITICAL: Stay in SEO phase so user can review updated content - don't auto-progress
|
||||
// User will manually navigate to publish when ready
|
||||
// This prevents blank screen by keeping user in SEO phase where BlogEditor is visible
|
||||
// No action needed - already in SEO phase, stay here
|
||||
} else {
|
||||
// User is NOT in SEO phase - can progress to publish
|
||||
// This handles cases where user navigates away and comes back
|
||||
// Only auto-progress if user is already in a different phase (not actively in SEO)
|
||||
if (currentPhase !== 'publish') {
|
||||
setCurrentPhase('publish');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [research, outline, outlineConfirmed, hasContent, contentConfirmed, seoAnalysis, seoMetadata, seoRecommendationsApplied, currentPhase, userSelectedPhase]);
|
||||
|
||||
const navigateToPhase = useCallback((phaseId: string) => {
|
||||
// Minimal debounce (200ms) to avoid race conditions on rapid clicks
|
||||
const now = Date.now();
|
||||
if (now - lastClickAtRef.current < 200) { return; }
|
||||
lastClickAtRef.current = now;
|
||||
|
||||
const phase = phases.find(p => p.id === phaseId);
|
||||
|
||||
if (phase && !phase.disabled) {
|
||||
setCurrentPhase(phaseId);
|
||||
setUserSelectedPhase(true); // Mark that user has manually selected a phase
|
||||
} else {
|
||||
// Quietly ignore blocked navigation
|
||||
}
|
||||
}, [phases, currentPhase]);
|
||||
|
||||
// Reset user selection when a new phase is completed (to allow auto-progression)
|
||||
const resetUserSelection = () => {
|
||||
setUserSelectedPhase(false);
|
||||
};
|
||||
|
||||
return {
|
||||
phases,
|
||||
currentPhase,
|
||||
navigateToPhase,
|
||||
setCurrentPhase,
|
||||
resetUserSelection
|
||||
};
|
||||
};
|
||||
|
||||
export default usePhaseNavigation;
|
||||
@@ -166,15 +166,53 @@ export interface BlogSectionResponse {
|
||||
continuity_metrics?: { flow?: number; consistency?: number; progression?: number };
|
||||
}
|
||||
|
||||
export interface BlogSEOActionableRecommendation {
|
||||
category: string;
|
||||
priority: 'High' | 'Medium' | 'Low' | string;
|
||||
recommendation: string;
|
||||
impact: string;
|
||||
}
|
||||
|
||||
export interface BlogSEOAnalysisSummary {
|
||||
overall_grade: string;
|
||||
status: string;
|
||||
strongest_category: string;
|
||||
weakest_category: string;
|
||||
key_strengths: string[];
|
||||
key_weaknesses: string[];
|
||||
ai_summary: string;
|
||||
}
|
||||
|
||||
export interface BlogSEOAnalyzeResponse {
|
||||
success: boolean;
|
||||
seo_score: number;
|
||||
density: Record<string, any>;
|
||||
structure: Record<string, any>;
|
||||
readability: Record<string, any>;
|
||||
link_suggestions: any[];
|
||||
image_alt_status: Record<string, any>;
|
||||
recommendations: string[];
|
||||
analysis_id?: string;
|
||||
overall_score: number;
|
||||
category_scores: Record<string, number>;
|
||||
analysis_summary: BlogSEOAnalysisSummary;
|
||||
actionable_recommendations: BlogSEOActionableRecommendation[];
|
||||
detailed_analysis?: any;
|
||||
visualization_data?: any;
|
||||
generated_at?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BlogSEOApplyRecommendationsRequest {
|
||||
title: string;
|
||||
sections: Array<{ id: string; heading: string; content: string }>;
|
||||
outline: BlogOutlineSection[];
|
||||
research: Record<string, any>;
|
||||
recommendations: BlogSEOActionableRecommendation[];
|
||||
persona?: Record<string, any>;
|
||||
tone?: string;
|
||||
audience?: string;
|
||||
}
|
||||
|
||||
export interface BlogSEOApplyRecommendationsResponse {
|
||||
success: boolean;
|
||||
title?: string;
|
||||
sections: Array<{ id: string; heading: string; content: string; notes?: string[] }>;
|
||||
applied?: Array<{ category: string; summary: string }>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BlogSEOMetadataResponse {
|
||||
@@ -263,6 +301,11 @@ export const blogWriterApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async applySeoRecommendations(payload: BlogSEOApplyRecommendationsRequest): Promise<BlogSEOApplyRecommendationsResponse> {
|
||||
const { data } = await apiClient.post('/api/blog/seo/apply-recommendations', payload);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Flow Analysis APIs
|
||||
async analyzeFlowBasic(payload: {
|
||||
title: string;
|
||||
|
||||
99
frontend/src/utils/debug.ts
Normal file
99
frontend/src/utils/debug.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Debug utility for controlling frontend logging
|
||||
*/
|
||||
|
||||
// Check for debug mode via localStorage or URL parameter
|
||||
const getDebugMode = (): boolean => {
|
||||
// Check URL parameter first (e.g., ?debug=true)
|
||||
if (typeof window !== 'undefined') {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlDebug = urlParams.get('debug');
|
||||
if (urlDebug === 'true') return true;
|
||||
if (urlDebug === 'false') return false;
|
||||
|
||||
// Check localStorage
|
||||
const stored = localStorage.getItem('alwrity-debug');
|
||||
if (stored === 'true') return true;
|
||||
if (stored === 'false') return false;
|
||||
}
|
||||
|
||||
// Default to false in production, true in development
|
||||
return process.env.NODE_ENV === 'development';
|
||||
};
|
||||
|
||||
let isDebugMode = getDebugMode();
|
||||
|
||||
export const debug = {
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
isEnabled: () => isDebugMode,
|
||||
|
||||
/**
|
||||
* Enable debug mode
|
||||
*/
|
||||
enable: () => {
|
||||
isDebugMode = true;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('alwrity-debug', 'true');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable debug mode
|
||||
*/
|
||||
disable: () => {
|
||||
isDebugMode = false;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('alwrity-debug', 'false');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle debug mode
|
||||
*/
|
||||
toggle: () => {
|
||||
if (isDebugMode) {
|
||||
debug.disable();
|
||||
} else {
|
||||
debug.enable();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log a message only if debug mode is enabled
|
||||
*/
|
||||
log: (message: string, ...args: any[]) => {
|
||||
if (isDebugMode) {
|
||||
console.log(`🔍 ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log an error (always shown)
|
||||
*/
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`❌ ${message}`, ...args);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log a warning (always shown)
|
||||
*/
|
||||
warn: (message: string, ...args: any[]) => {
|
||||
console.warn(`⚠️ ${message}`, ...args);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log an info message (always shown)
|
||||
*/
|
||||
info: (message: string, ...args: any[]) => {
|
||||
console.info(`ℹ️ ${message}`, ...args);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose global toggle for easy access
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).toggleDebug = debug.toggle;
|
||||
(window as any).debugMode = debug;
|
||||
}
|
||||
|
||||
18
frontend/src/utils/imageBus.ts
Normal file
18
frontend/src/utils/imageBus.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
type ImagePayload = { base64: string; provider?: string; model?: string };
|
||||
|
||||
const subscribers = new Set<(p: ImagePayload) => void>();
|
||||
|
||||
export function publishImage(payload: ImagePayload) {
|
||||
subscribers.forEach((cb) => {
|
||||
try { cb(payload); } catch {}
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribeImage(cb: (p: ImagePayload) => void) {
|
||||
subscribers.add(cb);
|
||||
return () => {
|
||||
subscribers.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user