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 fastapi import APIRouter, HTTPException
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from models.blog_models import (
|
from models.blog_models import (
|
||||||
@@ -29,6 +30,7 @@ from models.blog_models import (
|
|||||||
HallucinationCheckResponse,
|
HallucinationCheckResponse,
|
||||||
)
|
)
|
||||||
from services.blog_writer.blog_service import BlogWriterService
|
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 .task_manager import task_manager
|
||||||
from .cache_manager import cache_manager
|
from .cache_manager import cache_manager
|
||||||
from models.blog_models import MediumBlogGenerateRequest
|
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"])
|
router = APIRouter(prefix="/api/blog", tags=["AI Blog Writer"])
|
||||||
|
|
||||||
service = BlogWriterService()
|
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")
|
@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]:
|
async def get_outline_status(task_id: str) -> Dict[str, Any]:
|
||||||
"""Get the status of an outline generation operation."""
|
"""Get the status of an outline generation operation."""
|
||||||
try:
|
try:
|
||||||
status = task_manager.get_task_status(task_id)
|
status = await task_manager.get_task_status(task_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
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))
|
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")
|
@router.get("/section/{section_id}/continuity")
|
||||||
async def get_section_continuity(section_id: str) -> Dict[str, Any]:
|
async def get_section_continuity(section_id: str) -> Dict[str, Any]:
|
||||||
"""Fetch last computed continuity metrics for a section (if available)."""
|
"""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):
|
async def medium_generation_status(task_id: str):
|
||||||
"""Poll status for medium blog generation task."""
|
"""Poll status for medium blog generation task."""
|
||||||
try:
|
try:
|
||||||
status = task_manager.get_task_status(task_id)
|
status = await task_manager.get_task_status(task_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return status
|
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):
|
async def rewrite_status(task_id: str):
|
||||||
"""Poll status for blog rewrite task."""
|
"""Poll status for blog rewrite task."""
|
||||||
try:
|
try:
|
||||||
status = service.task_manager.get_task_status(task_id)
|
status = await service.task_manager.get_task_status(task_id)
|
||||||
if status is None:
|
if status is None:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ class TaskManager:
|
|||||||
task_id = self.create_task("medium_generation")
|
task_id = self.create_task("medium_generation")
|
||||||
asyncio.create_task(self._run_medium_generation_task(task_id, request))
|
asyncio.create_task(self._run_medium_generation_task(task_id, request))
|
||||||
return task_id
|
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):
|
async def _run_research_task(self, task_id: str, request: BlogResearchRequest):
|
||||||
"""Background task to run research and update status with progress messages."""
|
"""Background task to run research and update status with progress messages."""
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ from typing import Dict, Any, List
|
|||||||
from ..models.story_models import FacebookStoryRequest, FacebookStoryResponse
|
from ..models.story_models import FacebookStoryRequest, FacebookStoryResponse
|
||||||
from .base_service import FacebookWriterBaseService
|
from .base_service import FacebookWriterBaseService
|
||||||
try:
|
try:
|
||||||
from ...services.llm_providers.text_to_image_generation.gen_gemini_images import (
|
from ...services.llm_providers.main_image_generation import generate_image
|
||||||
generate_gemini_images_base64,
|
from base64 import b64encode
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
generate_gemini_images_base64 = None # type: ignore
|
generate_image = None # type: ignore
|
||||||
|
b64encode = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class FacebookStoryService(FacebookWriterBaseService):
|
class FacebookStoryService(FacebookWriterBaseService):
|
||||||
@@ -50,22 +50,29 @@ class FacebookStoryService(FacebookWriterBaseService):
|
|||||||
# Generate visual suggestions and engagement tips
|
# Generate visual suggestions and engagement tips
|
||||||
visual_suggestions = self._generate_visual_suggestions(actual_story_type, request.visual_options)
|
visual_suggestions = self._generate_visual_suggestions(actual_story_type, request.visual_options)
|
||||||
engagement_tips = self._generate_engagement_tips("story")
|
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] = []
|
images_base64: List[str] = []
|
||||||
try:
|
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 (
|
img_prompt = request.visual_options.background_image_prompt or (
|
||||||
f"Facebook story background for {request.business_type}. "
|
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."
|
f"Style: {actual_tone}. Type: {actual_story_type}. Vertical mobile 9:16, high contrast, legible overlay space."
|
||||||
)
|
)
|
||||||
images_base64 = generate_gemini_images_base64(
|
# Generate image using unified system (9:16 aspect ratio = 1080x1920)
|
||||||
img_prompt,
|
result = generate_image(
|
||||||
enhance_prompt=False,
|
prompt=img_prompt,
|
||||||
aspect_ratio="9:16",
|
options={
|
||||||
max_retries=2,
|
"provider": "gemini", # Facebook stories use Gemini
|
||||||
initial_retry_delay=1.0,
|
"width": 1080,
|
||||||
) or []
|
"height": 1920,
|
||||||
except Exception:
|
}
|
||||||
|
)
|
||||||
|
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 = []
|
images_base64 = []
|
||||||
|
|
||||||
return FacebookStoryResponse(
|
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
|
# Import LinkedIn image generation router
|
||||||
from api.linkedin_image_generation import router as linkedin_image_router
|
from api.linkedin_image_generation import router as linkedin_image_router
|
||||||
from api.brainstorm import router as brainstorm_router
|
from api.brainstorm import router as brainstorm_router
|
||||||
|
from api.images import router as images_router
|
||||||
|
|
||||||
# Import hallucination detector router
|
# Import hallucination detector router
|
||||||
from api.hallucination_detector import router as 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
|
# Include platform analytics router
|
||||||
from routers.platform_analytics import router as platform_analytics_router
|
from routers.platform_analytics import router as platform_analytics_router
|
||||||
app.include_router(platform_analytics_router)
|
app.include_router(platform_analytics_router)
|
||||||
|
app.include_router(images_router)
|
||||||
|
|
||||||
# Setup frontend serving using modular utilities
|
# Setup frontend serving using modular utilities
|
||||||
frontend_serving.setup_frontend_serving()
|
frontend_serving.setup_frontend_serving()
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ class BlogSEOMetadataRequest(BaseModel):
|
|||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
keywords: List[str] = []
|
keywords: List[str] = []
|
||||||
research_data: Optional[Dict[str, Any]] = None
|
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):
|
class BlogSEOMetadataResponse(BaseModel):
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ httpx>=0.27.2,<0.28.0
|
|||||||
|
|
||||||
# AI/ML dependencies
|
# AI/ML dependencies
|
||||||
openai>=1.3.0
|
openai>=1.3.0
|
||||||
anthropic>=0.7.0
|
|
||||||
mistralai>=0.0.12
|
|
||||||
google-genai>=1.0.0
|
google-genai>=1.0.0
|
||||||
google-ai-generativelanguage>=0.6.18,<0.7.0
|
|
||||||
|
|
||||||
google-api-python-client>=2.100.0
|
google-api-python-client>=2.100.0
|
||||||
google-auth>=2.23.0
|
google-auth>=2.23.0
|
||||||
google-auth-oauthlib>=1.0.0
|
google-auth-oauthlib>=1.0.0
|
||||||
@@ -53,6 +52,7 @@ nltk>=3.8.0
|
|||||||
|
|
||||||
# Image and audio processing for Stability AI
|
# Image and audio processing for Stability AI
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
|
huggingface_hub>=0.24.0
|
||||||
scikit-learn>=1.3.0
|
scikit-learn>=1.3.0
|
||||||
|
|
||||||
# Testing dependencies
|
# 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 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 .source_url_manager import SourceURLManager
|
||||||
from .context_memory import ContextMemory
|
from .context_memory import ContextMemory
|
||||||
from .transition_generator import TransitionGenerator
|
from .transition_generator import TransitionGenerator
|
||||||
@@ -15,24 +17,37 @@ from .flow_analyzer import FlowAnalyzer
|
|||||||
|
|
||||||
class EnhancedContentGenerator:
|
class EnhancedContentGenerator:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.provider = GeminiGroundedProvider()
|
|
||||||
self.url_manager = SourceURLManager()
|
self.url_manager = SourceURLManager()
|
||||||
self.memory = ContextMemory(max_entries=12)
|
self.memory = ContextMemory(max_entries=12)
|
||||||
self.transitioner = TransitionGenerator()
|
self.transitioner = TransitionGenerator()
|
||||||
self.flow = FlowAnalyzer()
|
self.flow = FlowAnalyzer()
|
||||||
|
|
||||||
async def generate_section(self, section: Any, research: Any, mode: str = "polished") -> Dict[str, Any]:
|
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)
|
prev_summary = self.memory.build_previous_sections_summary(limit=2)
|
||||||
prompt = self._build_prompt(section, research, prev_summary)
|
urls = self.url_manager.pick_relevant_urls(section, research)
|
||||||
result = await self.provider.generate_grounded_content(
|
prompt = self._build_prompt(section, research, prev_summary, urls)
|
||||||
prompt=prompt,
|
# Provider-agnostic text generation (respect GPT_PROVIDER & circuit-breaker)
|
||||||
content_type="linkedin_article",
|
content_text: str = ""
|
||||||
temperature=0.6 if mode == "polished" else 0.8,
|
try:
|
||||||
max_tokens=2048,
|
ai_resp = llm_text_gen(
|
||||||
urls=urls,
|
prompt=prompt,
|
||||||
mode=mode,
|
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
|
# Generate transition and compute intelligent flow metrics
|
||||||
previous_text = prev_summary
|
previous_text = prev_summary
|
||||||
current_text = result.get("content", "")
|
current_text = result.get("content", "")
|
||||||
@@ -56,19 +71,22 @@ class EnhancedContentGenerator:
|
|||||||
pass
|
pass
|
||||||
return result
|
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')
|
heading = getattr(section, 'heading', 'Section')
|
||||||
key_points = getattr(section, 'key_points', [])
|
key_points = getattr(section, 'key_points', [])
|
||||||
keywords = getattr(section, 'keywords', [])
|
keywords = getattr(section, 'keywords', [])
|
||||||
target_words = getattr(section, 'target_words', 300)
|
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 (
|
return (
|
||||||
f"You are writing the blog section '{heading}'.\n\n"
|
f"You are writing the blog section '{heading}'.\n\n"
|
||||||
f"Context summary: {prev_summary}\n"
|
f"Context summary (previous sections): {prev_summary}\n\n"
|
||||||
f"Key points: {', '.join(key_points)}\n"
|
f"Authoring requirements:\n"
|
||||||
f"Keywords: {', '.join(keywords)}\n"
|
f"- Target word count: ~{target_words}\n"
|
||||||
f"Target word count: {target_words}.\n"
|
f"- Use the following key points: {', '.join(key_points)}\n"
|
||||||
"Use only factual info from provided sources; add short transition, then body."
|
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,
|
MediumGeneratedSection,
|
||||||
ResearchSource,
|
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
|
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)}"
|
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,
|
prompt=prompt,
|
||||||
schema=schema,
|
json_struct=schema,
|
||||||
temperature=0.2,
|
|
||||||
max_tokens=8192,
|
|
||||||
system_prompt=system,
|
system_prompt=system,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -275,11 +275,17 @@ class BlogWriterService:
|
|||||||
# Initialize metadata generator
|
# Initialize metadata generator
|
||||||
metadata_generator = BlogSEOMetadataGenerator()
|
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(
|
metadata_results = await metadata_generator.generate_comprehensive_metadata(
|
||||||
blog_content=request.content,
|
blog_content=request.content,
|
||||||
blog_title=request.title or "Untitled Blog Post",
|
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
|
# Convert to BlogSEOMetadataResponse format
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Return JSON format:
|
|||||||
}}"""
|
}}"""
|
||||||
|
|
||||||
try:
|
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 = {
|
optimization_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -64,11 +64,10 @@ Return JSON format:
|
|||||||
"propertyOrdering": ["outline"]
|
"propertyOrdering": ["outline"]
|
||||||
}
|
}
|
||||||
|
|
||||||
optimized_data = gemini_structured_json_response(
|
optimized_data = llm_text_gen(
|
||||||
prompt=optimization_prompt,
|
prompt=optimization_prompt,
|
||||||
schema=optimization_schema,
|
json_struct=optimization_schema,
|
||||||
temperature=0.3,
|
system_prompt=None
|
||||||
max_tokens=6000 # Match main outline generator
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle the new schema format with "outline" wrapper
|
# 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]:
|
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."""
|
"""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
|
from api.blog_writer.task_manager import task_manager
|
||||||
|
|
||||||
max_retries = 2 # Conservative retry for expensive API calls
|
max_retries = 2 # Conservative retry for expensive API calls
|
||||||
@@ -29,17 +29,16 @@ class ResponseProcessor:
|
|||||||
for attempt in range(max_retries + 1):
|
for attempt in range(max_retries + 1):
|
||||||
try:
|
try:
|
||||||
if task_id:
|
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,
|
prompt=prompt,
|
||||||
schema=schema,
|
json_struct=schema,
|
||||||
temperature=0.3,
|
system_prompt=None
|
||||||
max_tokens=6000 # Increased further to avoid truncation
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log response for debugging
|
# 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
|
# Check for errors in the response
|
||||||
if isinstance(outline_data, dict) and 'error' in outline_data:
|
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 "503" in error_msg and "overloaded" in error_msg and attempt < max_retries:
|
||||||
if task_id:
|
if task_id:
|
||||||
await task_manager.update_progress(task_id, f"⚠️ AI service overloaded, retrying in {retry_delay} seconds...")
|
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)
|
await asyncio.sleep(retry_delay)
|
||||||
continue
|
continue
|
||||||
elif "No valid structured response content found" in error_msg and attempt < max_retries:
|
elif "No valid structured response content found" in error_msg and attempt < max_retries:
|
||||||
if task_id:
|
if task_id:
|
||||||
await task_manager.update_progress(task_id, f"⚠️ Invalid response format, retrying in {retry_delay} seconds...")
|
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)
|
await asyncio.sleep(retry_delay)
|
||||||
continue
|
continue
|
||||||
else:
|
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']}")
|
raise ValueError(f"AI outline generation failed: {outline_data['error']}")
|
||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
@@ -69,7 +68,7 @@ class ResponseProcessor:
|
|||||||
await asyncio.sleep(retry_delay)
|
await asyncio.sleep(retry_delay)
|
||||||
continue
|
continue
|
||||||
else:
|
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
|
# If we get here, the response is valid
|
||||||
return outline_data
|
return outline_data
|
||||||
@@ -79,7 +78,7 @@ class ResponseProcessor:
|
|||||||
if ("503" in error_str or "overloaded" in error_str) and attempt < max_retries:
|
if ("503" in error_str or "overloaded" in error_str) and attempt < max_retries:
|
||||||
if task_id:
|
if task_id:
|
||||||
await task_manager.update_progress(task_id, f"⚠️ AI service error, retrying in {retry_delay} seconds...")
|
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)
|
await asyncio.sleep(retry_delay)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class SectionEnhancer:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
try:
|
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 = {
|
enhancement_schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -58,11 +58,10 @@ class SectionEnhancer:
|
|||||||
"required": ["heading", "subheadings", "key_points", "target_words", "keywords"]
|
"required": ["heading", "subheadings", "key_points", "target_words", "keywords"]
|
||||||
}
|
}
|
||||||
|
|
||||||
enhanced_data = gemini_structured_json_response(
|
enhanced_data = llm_text_gen(
|
||||||
prompt=enhancement_prompt,
|
prompt=enhancement_prompt,
|
||||||
schema=enhancement_schema,
|
json_struct=enhancement_schema,
|
||||||
temperature=0.4,
|
system_prompt=None
|
||||||
max_tokens=1000
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(enhanced_data, dict) and 'error' not in enhanced_data:
|
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
|
AI validation response
|
||||||
"""
|
"""
|
||||||
try:
|
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,
|
prompt=prompt,
|
||||||
temperature=0.3,
|
json_struct=None,
|
||||||
top_p=0.9,
|
|
||||||
n=1,
|
|
||||||
max_tokens=2000,
|
|
||||||
system_prompt=None
|
system_prompt=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ import re
|
|||||||
import textstat
|
import textstat
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from loguru import logger
|
from utils.logger_utils import get_service_logger
|
||||||
|
|
||||||
from services.seo_analyzer import (
|
from services.seo_analyzer import (
|
||||||
ContentAnalyzer, KeywordAnalyzer,
|
ContentAnalyzer, KeywordAnalyzer,
|
||||||
URLStructureAnalyzer, AIInsightGenerator
|
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:
|
class BlogContentSEOAnalyzer:
|
||||||
@@ -24,11 +24,13 @@ class BlogContentSEOAnalyzer:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the blog content SEO analyzer"""
|
"""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.content_analyzer = ContentAnalyzer()
|
||||||
self.keyword_analyzer = KeywordAnalyzer()
|
self.keyword_analyzer = KeywordAnalyzer()
|
||||||
self.url_analyzer = URLStructureAnalyzer()
|
self.url_analyzer = URLStructureAnalyzer()
|
||||||
self.ai_insights = AIInsightGenerator()
|
self.ai_insights = AIInsightGenerator()
|
||||||
self.gemini_provider = gemini_structured_json_response
|
|
||||||
|
|
||||||
logger.info("BlogContentSEOAnalyzer initialized")
|
logger.info("BlogContentSEOAnalyzer initialized")
|
||||||
|
|
||||||
@@ -598,7 +600,7 @@ class BlogContentSEOAnalyzer:
|
|||||||
return recommendations
|
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]:
|
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:
|
try:
|
||||||
# Prepare context for AI analysis
|
# Prepare context for AI analysis
|
||||||
context = {
|
context = {
|
||||||
@@ -610,7 +612,6 @@ class BlogContentSEOAnalyzer:
|
|||||||
# Create AI prompt for structured analysis
|
# Create AI prompt for structured analysis
|
||||||
prompt = self._create_ai_analysis_prompt(context)
|
prompt = self._create_ai_analysis_prompt(context)
|
||||||
|
|
||||||
# Get structured response from Gemini
|
|
||||||
schema = {
|
schema = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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,
|
prompt=prompt,
|
||||||
schema=schema,
|
json_struct=schema,
|
||||||
temperature=0.2,
|
system_prompt=None
|
||||||
max_tokens=8192
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return ai_response
|
return ai_response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AI analysis failed: {e}")
|
logger.error(f"AI analysis failed: {e}")
|
||||||
# Fail fast - don't return mock data
|
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def _create_ai_analysis_prompt(self, context: Dict[str, Any]) -> str:
|
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 typing import Dict, Any, List, Optional
|
||||||
from loguru import logger
|
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:
|
class BlogSEOMetadataGenerator:
|
||||||
@@ -20,14 +20,15 @@ class BlogSEOMetadataGenerator:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""Initialize the metadata generator"""
|
"""Initialize the metadata generator"""
|
||||||
self.gemini_provider = gemini_structured_json_response
|
|
||||||
logger.info("BlogSEOMetadataGenerator initialized")
|
logger.info("BlogSEOMetadataGenerator initialized")
|
||||||
|
|
||||||
async def generate_comprehensive_metadata(
|
async def generate_comprehensive_metadata(
|
||||||
self,
|
self,
|
||||||
blog_content: str,
|
blog_content: str,
|
||||||
blog_title: 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]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Generate comprehensive SEO metadata using maximum 2 AI calls
|
Generate comprehensive SEO metadata using maximum 2 AI calls
|
||||||
@@ -36,6 +37,8 @@ class BlogSEOMetadataGenerator:
|
|||||||
blog_content: The blog content to analyze
|
blog_content: The blog content to analyze
|
||||||
blog_title: The blog title
|
blog_title: The blog title
|
||||||
research_data: Research data containing keywords and insights
|
research_data: Research data containing keywords and insights
|
||||||
|
outline: Outline structure with sections and headings
|
||||||
|
seo_analysis: SEO analysis results from previous phase
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Comprehensive metadata including all SEO elements
|
Comprehensive metadata including all SEO elements
|
||||||
@@ -49,11 +52,15 @@ class BlogSEOMetadataGenerator:
|
|||||||
|
|
||||||
# Call 1: Generate core SEO metadata (parallel with Call 2)
|
# Call 1: Generate core SEO metadata (parallel with Call 2)
|
||||||
logger.info("Generating core SEO metadata")
|
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)
|
# Call 2: Generate social media and structured data (parallel with Call 1)
|
||||||
logger.info("Generating social media and structured data")
|
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
|
# Wait for both calls to complete
|
||||||
core_metadata, social_metadata = await asyncio.gather(
|
core_metadata, social_metadata = await asyncio.gather(
|
||||||
@@ -105,12 +112,16 @@ class BlogSEOMetadataGenerator:
|
|||||||
self,
|
self,
|
||||||
blog_content: str,
|
blog_content: str,
|
||||||
blog_title: 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]:
|
) -> Dict[str, Any]:
|
||||||
"""Generate core SEO metadata (Call 1)"""
|
"""Generate core SEO metadata (Call 1)"""
|
||||||
try:
|
try:
|
||||||
# Create comprehensive prompt for core metadata
|
# 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
|
# Define simplified structured schema for core metadata
|
||||||
schema = {
|
schema = {
|
||||||
@@ -155,17 +166,26 @@ class BlogSEOMetadataGenerator:
|
|||||||
"required": ["seo_title", "meta_description", "url_slug", "blog_tags", "blog_categories", "social_hashtags", "reading_time", "focus_keyword"]
|
"required": ["seo_title", "meta_description", "url_slug", "blog_tags", "blog_categories", "social_hashtags", "reading_time", "focus_keyword"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get structured response from Gemini
|
# Get structured response using provider-agnostic llm_text_gen
|
||||||
ai_response = self.gemini_provider(
|
ai_response_raw = llm_text_gen(
|
||||||
prompt,
|
prompt=prompt,
|
||||||
schema,
|
json_struct=schema,
|
||||||
temperature=0.3,
|
system_prompt=None
|
||||||
max_tokens=2048
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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
|
# Check if we got a valid response
|
||||||
if not ai_response or not isinstance(ai_response, dict):
|
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
|
# Return fallback response
|
||||||
primary_keywords = ', '.join(keywords_data.get('primary_keywords', ['content']))
|
primary_keywords = ', '.join(keywords_data.get('primary_keywords', ['content']))
|
||||||
word_count = len(blog_content.split())
|
word_count = len(blog_content.split())
|
||||||
@@ -193,12 +213,16 @@ class BlogSEOMetadataGenerator:
|
|||||||
self,
|
self,
|
||||||
blog_content: str,
|
blog_content: str,
|
||||||
blog_title: 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]:
|
) -> Dict[str, Any]:
|
||||||
"""Generate social media and structured data (Call 2)"""
|
"""Generate social media and structured data (Call 2)"""
|
||||||
try:
|
try:
|
||||||
# Create comprehensive prompt for social metadata
|
# 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
|
# Define simplified structured schema for social metadata
|
||||||
schema = {
|
schema = {
|
||||||
@@ -246,17 +270,26 @@ class BlogSEOMetadataGenerator:
|
|||||||
"required": ["open_graph", "twitter_card", "json_ld_schema"]
|
"required": ["open_graph", "twitter_card", "json_ld_schema"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get structured response from Gemini
|
# Get structured response using provider-agnostic llm_text_gen
|
||||||
ai_response = self.gemini_provider(
|
ai_response_raw = llm_text_gen(
|
||||||
prompt,
|
prompt=prompt,
|
||||||
schema,
|
json_struct=schema,
|
||||||
temperature=0.3,
|
system_prompt=None
|
||||||
max_tokens=2048
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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
|
# 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'):
|
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 fallback response
|
||||||
return {
|
return {
|
||||||
'open_graph': {
|
'open_graph': {
|
||||||
@@ -301,11 +334,47 @@ class BlogSEOMetadataGenerator:
|
|||||||
logger.error(f"Social metadata generation failed: {e}")
|
logger.error(f"Social metadata generation failed: {e}")
|
||||||
raise 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(
|
def _create_core_metadata_prompt(
|
||||||
self,
|
self,
|
||||||
blog_content: str,
|
blog_content: str,
|
||||||
blog_title: 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:
|
) -> str:
|
||||||
"""Create high-quality prompt for core metadata generation"""
|
"""Create high-quality prompt for core metadata generation"""
|
||||||
|
|
||||||
@@ -314,30 +383,106 @@ class BlogSEOMetadataGenerator:
|
|||||||
search_intent = keywords_data.get('search_intent', 'informational')
|
search_intent = keywords_data.get('search_intent', 'informational')
|
||||||
target_audience = keywords_data.get('target_audience', 'general')
|
target_audience = keywords_data.get('target_audience', 'general')
|
||||||
industry = keywords_data.get('industry', 'general')
|
industry = keywords_data.get('industry', 'general')
|
||||||
|
|
||||||
# Calculate word count for reading time estimation
|
|
||||||
word_count = len(blog_content.split())
|
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"""
|
prompt = f"""
|
||||||
Generate SEO metadata for this blog post.
|
Generate comprehensive, personalized SEO metadata for this blog post.
|
||||||
|
|
||||||
BLOG TITLE: {blog_title}
|
=== BLOG CONTENT CONTEXT ===
|
||||||
BLOG CONTENT: {blog_content[:1000]}...
|
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}
|
PRIMARY KEYWORDS: {primary_keywords}
|
||||||
SEMANTIC KEYWORDS: {semantic_keywords}
|
SEMANTIC KEYWORDS: {semantic_keywords}
|
||||||
WORD COUNT: {word_count}
|
SEARCH INTENT: {search_intent}
|
||||||
|
TARGET AUDIENCE: {target_audience}
|
||||||
|
INDUSTRY: {industry}
|
||||||
|
|
||||||
Generate:
|
{seo_context}
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
return prompt
|
||||||
|
|
||||||
@@ -345,7 +490,9 @@ Make it compelling and SEO-optimized.
|
|||||||
self,
|
self,
|
||||||
blog_content: str,
|
blog_content: str,
|
||||||
blog_title: 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:
|
) -> str:
|
||||||
"""Create high-quality prompt for social metadata generation"""
|
"""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')
|
search_intent = keywords_data.get('search_intent', 'informational')
|
||||||
target_audience = keywords_data.get('target_audience', 'general')
|
target_audience = keywords_data.get('target_audience', 'general')
|
||||||
industry = keywords_data.get('industry', 'general')
|
industry = keywords_data.get('industry', 'general')
|
||||||
|
|
||||||
current_date = datetime.now().isoformat()
|
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"""
|
prompt = f"""
|
||||||
Generate social media metadata for this blog post.
|
Generate engaging social media metadata for this blog post.
|
||||||
|
|
||||||
BLOG TITLE: {blog_title}
|
=== CONTENT ===
|
||||||
BLOG CONTENT: {blog_content[:800]}...
|
TITLE: {blog_title}
|
||||||
PRIMARY KEYWORDS: {primary_keywords}
|
CONTENT: {content_preview}
|
||||||
|
{outline_context}
|
||||||
|
{seo_context}
|
||||||
|
KEYWORDS: {primary_keywords}
|
||||||
|
TARGET AUDIENCE: {target_audience}
|
||||||
|
INDUSTRY: {industry}
|
||||||
CURRENT DATE: {current_date}
|
CURRENT DATE: {current_date}
|
||||||
|
|
||||||
Generate:
|
=== GENERATION REQUIREMENTS ===
|
||||||
|
|
||||||
1. OPEN GRAPH (Facebook/LinkedIn):
|
1. OPEN GRAPH (Facebook/LinkedIn):
|
||||||
- title: 60 chars max
|
- title: 60 chars max, include primary keyword, compelling for {target_audience}
|
||||||
- description: 160 chars max
|
- description: 160 chars max, include CTA and value proposition
|
||||||
- image: image URL
|
- image: Suggest an appropriate image URL (placeholder if none available)
|
||||||
- type: "article"
|
- type: "article"
|
||||||
- site_name: site name
|
- site_name: Use appropriate site name for {industry} industry
|
||||||
- url: canonical URL
|
- url: Generate canonical URL structure
|
||||||
|
|
||||||
2. TWITTER CARD:
|
2. TWITTER CARD:
|
||||||
- card: "summary_large_image"
|
- card: "summary_large_image"
|
||||||
- title: 70 chars max
|
- title: 70 chars max, optimized for Twitter audience
|
||||||
- description: 200 chars max with hashtags
|
- description: 200 chars max with relevant hashtags inline
|
||||||
- image: image URL
|
- image: Match Open Graph image
|
||||||
- site: @sitename
|
- site: @yourwebsite (placeholder, user should update)
|
||||||
- creator: @author
|
- creator: @author (placeholder, user should update)
|
||||||
|
|
||||||
3. JSON-LD SCHEMA:
|
3. JSON-LD SCHEMA (Article):
|
||||||
- @context: "https://schema.org"
|
- @context: "https://schema.org"
|
||||||
- @type: "Article"
|
- @type: "Article"
|
||||||
- headline: article title
|
- headline: Article title (optimized)
|
||||||
- description: article description
|
- description: Article description (150-200 chars)
|
||||||
- author: {{"@type": "Person", "name": "Author Name"}}
|
- author: {{"@type": "Person", "name": "Author Name"}} (placeholder)
|
||||||
- publisher: {{"@type": "Organization", "name": "Site Name"}}
|
- publisher: {{"@type": "Organization", "name": "Site Name", "logo": {{"@type": "ImageObject", "url": "logo-url"}}}}
|
||||||
- datePublished: ISO date
|
- datePublished: {current_date}
|
||||||
- dateModified: ISO date
|
- dateModified: {current_date}
|
||||||
- mainEntityOfPage: canonical URL
|
- mainEntityOfPage: {{"@type": "WebPage", "@id": "canonical-url"}}
|
||||||
- keywords: array of keywords
|
- keywords: Array of primary and semantic keywords
|
||||||
- wordCount: word count
|
- 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
|
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
|
# Import existing infrastructure
|
||||||
from ...onboarding.api_key_manager import APIKeyManager
|
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
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -270,41 +270,57 @@ class LinkedInImageGenerator:
|
|||||||
|
|
||||||
async def _generate_with_gemini(self, prompt: str, aspect_ratio: str) -> Dict[str, Any]:
|
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:
|
Args:
|
||||||
prompt: Enhanced prompt for image generation
|
prompt: Enhanced prompt for image generation
|
||||||
aspect_ratio: Desired aspect ratio
|
aspect_ratio: Desired aspect ratio
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Generation result from Gemini
|
Generation result from image generation provider
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Use existing Gemini image generation function
|
# Map aspect ratio to dimensions (LinkedIn-optimized)
|
||||||
# This integrates with the current infrastructure
|
aspect_map = {
|
||||||
result = generate_gemini_image(prompt, aspect_ratio=aspect_ratio)
|
"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):
|
# Use unified image generation system (defaults to provider based on GPT_PROVIDER)
|
||||||
# Read the generated image
|
result = generate_image(
|
||||||
with open(result, 'rb') as f:
|
prompt=prompt,
|
||||||
image_data = f.read()
|
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 {
|
return {
|
||||||
'success': True,
|
'success': True,
|
||||||
'image_data': image_data,
|
'image_data': result.image_bytes,
|
||||||
'image_path': result
|
'image_path': None, # No file path, using bytes directly
|
||||||
|
'width': result.width,
|
||||||
|
'height': result.height,
|
||||||
|
'provider': result.provider,
|
||||||
|
'model': result.model,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Gemini image generation returned no result'
|
'error': 'Image generation returned no result'
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in Gemini image generation: {str(e)}")
|
logger.error(f"Error in image generation: {str(e)}")
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': f"Gemini generation failed: {str(e)}"
|
'error': f"Image generation failed: {str(e)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _process_generated_image(
|
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 os
|
||||||
import asyncio
|
import asyncio
|
||||||
import concurrent.futures
|
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import requests
|
import requests
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
|
||||||
try:
|
from services.llm_providers.main_text_generation import llm_text_gen
|
||||||
from google import genai
|
|
||||||
GOOGLE_GENAI_AVAILABLE = True
|
|
||||||
except Exception:
|
|
||||||
GOOGLE_GENAI_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -29,17 +26,10 @@ class WritingAssistantService:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.exa_api_key = os.getenv("EXA_API_KEY")
|
self.exa_api_key = os.getenv("EXA_API_KEY")
|
||||||
self.gemini_api_key = os.getenv("GEMINI_API_KEY")
|
|
||||||
|
|
||||||
if not self.exa_api_key:
|
if not self.exa_api_key:
|
||||||
logger.warning("EXA_API_KEY not configured; writing assistant will fail")
|
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
|
self.http_timeout_seconds = 15
|
||||||
|
|
||||||
# COST CONTROL: Daily usage limits
|
# COST CONTROL: Daily usage limits
|
||||||
@@ -151,9 +141,6 @@ class WritingAssistantService:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def _generate_continuation(self, text: str, sources: List[Dict[str, Any]]) -> tuple[str, float]:
|
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
|
# Build compact sources context block
|
||||||
source_blocks: List[str] = []
|
source_blocks: List[str] = []
|
||||||
for i, s in enumerate(sources[:5]):
|
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)"
|
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 = (
|
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. "
|
"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. "
|
"Match tone and topic. 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])."
|
"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 = (
|
user_prompt = (
|
||||||
@@ -179,17 +166,20 @@ class WritingAssistantService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_event_loop()
|
# Inter-call jitter to reduce burst rate limits
|
||||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
time.sleep(random.uniform(0.05, 0.15))
|
||||||
resp = await loop.run_in_executor(
|
|
||||||
executor,
|
ai_resp = llm_text_gen(
|
||||||
lambda: self.gemini_client.models.generate_content(
|
prompt=user_prompt,
|
||||||
model="gemini-1.5-flash", contents=f"{system_prompt}\n\n{user_prompt}"
|
json_struct=None,
|
||||||
),
|
system_prompt=system_prompt,
|
||||||
)
|
)
|
||||||
suggestion = (resp.text or "").strip()
|
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:
|
if not suggestion:
|
||||||
raise Exception("Gemini returned empty suggestion")
|
raise Exception("Assistive writer returned empty suggestion")
|
||||||
# naive confidence from number of sources present
|
# naive confidence from number of sources present
|
||||||
confidence = 0.7 if sources else 0.5
|
confidence = 0.7 if sources else 0.5
|
||||||
return suggestion, confidence
|
return suggestion, confidence
|
||||||
|
|||||||
@@ -24,27 +24,100 @@ The ALwrity Blog Writer is a powerful AI-driven content creation tool that helps
|
|||||||
|
|
||||||
## How It Works
|
## 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
|
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.
|
||||||
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
|
```mermaid
|
||||||
4. **Optimize and Publish** - Review SEO suggestions, make final edits, and publish your content
|
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
|
### What Happens Behind the Scenes
|
||||||
|
|
||||||
- **Research Phase**: AI searches the web for current information and sources
|
The Blog Writer leverages sophisticated AI orchestration to ensure quality at every stage:
|
||||||
- **Outline Generation**: Creates a logical structure with headings and key points
|
|
||||||
- **Content Writing**: Generates engaging, informative content for each section
|
- **Research Phase**: AI searches the web using Gemini's native Google Search integration for current, credible information and sources
|
||||||
- **Quality Checks**: Runs fact-checking and SEO analysis automatically
|
- **Outline Generation**: Creates logical structure with headings, key points, and source mapping using parallel processing
|
||||||
- **Publishing**: Formats content for your chosen platform
|
- **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
|
### User-Friendly Features
|
||||||
|
|
||||||
- **Progress Tracking**: See real-time progress for research and writing tasks
|
- **Progress Tracking**: See real-time progress for all long-running tasks with detailed status updates
|
||||||
- **Visual Editor**: Edit content with an easy-to-use WYSIWYG interface
|
- **Visual Editor**: Easy-to-use WYSIWYG interface with markdown support and live preview
|
||||||
- **Title Suggestions**: Choose from AI-generated title options
|
- **Title Suggestions**: Multiple AI-generated, SEO-scored title options to choose from
|
||||||
- **SEO Integration**: Get SEO suggestions as you write
|
- **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
|
## Content Types
|
||||||
|
|
||||||
@@ -130,30 +203,124 @@ The ALwrity Blog Writer is a powerful AI-driven content creation tool that helps
|
|||||||
|
|
||||||
## Advanced Features
|
## Advanced Features
|
||||||
|
|
||||||
### Content Templates
|
### ✨ Assistive Writing & Quick Edits
|
||||||
- **Industry-specific**: Tailored templates
|
- **Continue Writing**: AI-powered contextual suggestions as you type
|
||||||
- **Content Types**: Various formats
|
- **Smart Typing Assist**: Automatic suggestions after 20+ words
|
||||||
- **Brand Guidelines**: Consistent styling
|
- **Quick Edit Options**: Improve, expand, shorten, professionalize, add transitions, add data
|
||||||
- **Custom Templates**: Personalized formats
|
- **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
|
### 🔍 Fact-Checking & Quality Assurance
|
||||||
- **Team Editing**: Multiple contributors
|
- **Hallucination Detection**: AI-powered verification of claims and facts
|
||||||
- **Version Control**: Content history
|
- **Source Verification**: Automatic cross-checking against research sources
|
||||||
- **Comments**: Feedback system
|
- **Claim Analysis**: Detailed assessment of each verifiable statement
|
||||||
- **Approval Workflow**: Review process
|
- **Evidence Support**: Links to supporting or refuting sources
|
||||||
|
- **Quality Scoring**: Overall confidence metrics for content accuracy
|
||||||
|
|
||||||
### Automation
|
### 🖼️ Image Generation
|
||||||
- **Scheduled Publishing**: Automated posting
|
- **Section-Specific Images**: Generate images per blog section from the outline
|
||||||
- **Content Calendar**: Planning tools
|
- **AI-Powered Prompts**: Auto-suggest images based on section content
|
||||||
- **Social Sharing**: Auto-distribution
|
- **Advanced Options**: Stability AI, Hugging Face, Gemini
|
||||||
- **Performance Monitoring**: Analytics tracking
|
- **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
|
## Getting Started
|
||||||
|
|
||||||
1. **[Research Integration](research.md)** - Set up automated research
|
1. **[Research Integration](research.md)** - Comprehensive Phase 1 research capabilities
|
||||||
2. **[SEO Analysis](seo-analysis.md)** - Configure SEO optimization
|
2. **[Workflow Guide](workflow-guide.md)** - Step-by-step 6-phase workflow walkthrough
|
||||||
3. **[Implementation Spec](implementation-spec.md)** - Technical details
|
3. **[SEO Analysis](seo-analysis.md)** - Phase 4 & 5 optimization strategies
|
||||||
4. **[Best Practices](../../guides/best-practices.md)** - Optimization tips
|
4. **[Implementation Spec](implementation-spec.md)** - Technical architecture and API details
|
||||||
|
5. **[Best Practices](../../guides/best-practices.md)** - Advanced optimization tips
|
||||||
|
|
||||||
## Related Features
|
## 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
|
### Key Benefits
|
||||||
|
|
||||||
- **Comprehensive Research**: Gather information from multiple reliable sources
|
- **Comprehensive Research**: Gather information from multiple reliable sources with Google Search grounding
|
||||||
- **Fact Verification**: Verify claims and statistics automatically
|
- **Competitive Intelligence**: Identify content gaps and opportunities through competitor analysis
|
||||||
- **Source Attribution**: Provide proper citations and references
|
- **Keyword Intelligence**: Discover primary, secondary, and long-tail keyword opportunities
|
||||||
- **Trend Analysis**: Identify current trends and developments
|
- **Content Angles**: AI-generated unique content angles for maximum engagement
|
||||||
- **Competitive Intelligence**: Analyze competitor content and strategies
|
- **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
|
## 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
|
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.
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Academic Sources
|
#### Single API Call Efficiency
|
||||||
- **Research Papers**: Academic journals and research publications
|
- **One Request**: Comprehensive research in a single Gemini API call with Google Search grounding
|
||||||
- **Studies and Reports**: Industry studies and market research
|
- **Live Web Data**: Real-time access to current information from the web
|
||||||
- **White Papers**: Technical and business white papers
|
- **No Multi-Source Setup**: Eliminates need for multiple API integrations
|
||||||
- **Case Studies**: Real-world examples and case studies
|
- **Cost Effective**: Optimized token usage with focused research prompts
|
||||||
- **Expert Opinions**: Industry expert insights and analysis
|
- **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
|
- **Industry Reports**: Market research and industry analysis
|
||||||
- **Company Publications**: Official company blogs and reports
|
- **Expert Articles**: Authoritative blogs and professional content
|
||||||
- **Professional Networks**: LinkedIn articles and professional content
|
- **Academic Sources**: Research papers and studies
|
||||||
- **Trade Publications**: Industry-specific magazines and journals
|
- **Case Studies**: Real-world examples and implementations
|
||||||
- **Conference Materials**: Industry conference presentations and papers
|
- **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
|
Provide comprehensive analysis including:
|
||||||
- **Content Extraction**: Extract relevant information from sources
|
1. Current trends and insights (2024-2025)
|
||||||
- **Fact Identification**: Identify key facts, statistics, and claims
|
2. Key statistics and data points with sources
|
||||||
- **Quote Collection**: Gather relevant quotes and expert opinions
|
3. Industry expert opinions and quotes
|
||||||
- **Trend Identification**: Identify current trends and patterns
|
4. Recent developments and news
|
||||||
- **Gap Analysis**: Find information gaps and opportunities
|
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
|
Focus on factual, up-to-date information from credible sources.
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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
|
#### Content Gap Identification
|
||||||
- **Current Information**: Access to real-time data and updates
|
- **Top Competitors**: Identifies the most authoritative content on your topic
|
||||||
- **Trend Monitoring**: Track current trends and developments
|
- **Coverage Analysis**: Maps what competitors have covered thoroughly vs. superficially
|
||||||
- **News Integration**: Include latest news and updates
|
- **Gap Opportunities**: Highlights underexplored angles and missing information
|
||||||
- **Social Media Monitoring**: Track social media discussions
|
- **Unique Positioning**: Suggests how to differentiate your content
|
||||||
- **Market Data**: Access current market information
|
- **Competitive Advantages**: Identifies areas where you can exceed competitor quality
|
||||||
|
|
||||||
#### Dynamic Updates
|
#### Competitive Intelligence
|
||||||
- **Content Freshness**: Ensure content includes latest information
|
- **Content Depth**: Analyzes how thoroughly competitors cover topics
|
||||||
- **Trend Integration**: Incorporate current trends and developments
|
- **Keyword Usage**: Identifies keyword strategies in competitor content
|
||||||
- **News Relevance**: Include relevant recent news
|
- **Content Structure**: Evaluates how competitors organize information
|
||||||
- **Market Updates**: Include current market conditions
|
- **Engagement Patterns**: Notes what formats and angles work best
|
||||||
- **Expert Insights**: Access latest expert opinions
|
- **Market Positioning**: Understands where competitors sit in the market
|
||||||
|
|
||||||
### Source Verification
|
### 4. Keyword Intelligence
|
||||||
|
|
||||||
#### Credibility Assessment
|
Phase 1 provides comprehensive keyword analysis to optimize your content for search engines.
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Source Diversity
|
#### Primary, Secondary & Long-Tail Keywords
|
||||||
- **Multiple Perspectives**: Include diverse viewpoints and opinions
|
- **Primary Keywords**: Main topic keywords with highest search volume
|
||||||
- **Source Types**: Mix different types of sources
|
- **Secondary Keywords**: Supporting terms that reinforce the main topic
|
||||||
- **Geographic Diversity**: Include international sources
|
- **Long-Tail Keywords**: Specific, less competitive phrases with high intent
|
||||||
- **Temporal Range**: Include both recent and historical sources
|
- **Semantic Keywords**: Related terms that search engines associate with your topic
|
||||||
- **Expertise Levels**: Include both expert and general sources
|
- **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
|
### 5. Content Angle Generation
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Manual Review
|
AI generates unique content angles that make your blog stand out and engage your audience.
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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
|
### 6. Information Processing
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Research Summary
|
#### Data Collection & Extraction
|
||||||
- **Executive Summary**: High-level overview of research findings
|
- **Source Extraction**: Automatically extracts 10-20 credible sources from Google Search
|
||||||
- **Key Insights**: Main insights and discoveries
|
- **Fact Identification**: Identifies key facts, statistics, and claims with citations
|
||||||
- **Trend Analysis**: Analysis of current trends
|
- **Quote Collection**: Gathers relevant expert quotes with attribution
|
||||||
- **Gap Identification**: Information gaps and opportunities
|
- **Trend Identification**: Highlights current trends and patterns
|
||||||
- **Recommendations**: Research-based recommendations
|
- **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
|
## Research Output Structure
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Source Information
|
### Comprehensive Research Results
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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
|
#### Research Summary Example
|
||||||
- **Topic Development**: Develop topics based on research insights
|
```json
|
||||||
- **Content Structure**: Structure content based on research findings
|
{
|
||||||
- **Key Points**: Identify key points from research
|
"success": true,
|
||||||
- **Supporting Evidence**: Gather supporting evidence and examples
|
"sources": [
|
||||||
- **Expert Opinions**: Include relevant expert opinions
|
{
|
||||||
|
"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
|
## Use Cases for Different Audiences
|
||||||
- **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
|
|
||||||
|
|
||||||
### Content Enhancement
|
### For Technical Content Writers
|
||||||
|
**Scenario**: Writing a technical deep-dive on "React Performance Optimization"
|
||||||
|
|
||||||
#### Evidence-Based Content
|
**Phase 1 Delivers**:
|
||||||
- **Factual Accuracy**: Ensure all facts are accurate and verified
|
- Latest React documentation updates and best practices
|
||||||
- **Statistical Support**: Support claims with relevant statistics
|
- GitHub discussions and Stack Overflow solutions for optimization challenges
|
||||||
- **Expert Validation**: Include expert opinions and validation
|
- Academic research on frontend performance optimization
|
||||||
- **Case Studies**: Include relevant case studies and examples
|
- Real-world case studies from major tech companies
|
||||||
- **Trend Analysis**: Incorporate current trend analysis
|
- Technical keyword opportunities: "React performance hooks", "memoization strategies"
|
||||||
|
|
||||||
#### Credibility Building
|
**Value**: Eliminates hours of manual research across GitHub, documentation, and forums
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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
|
**Value**: Provides business intelligence without expensive consultants
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Research Platforms
|
### For Digital Marketing & SEO Professionals
|
||||||
- **Google Scholar**: Academic research and papers
|
**Scenario**: Content strategy for "Local SEO Best Practices"
|
||||||
- **ResearchGate**: Academic network and research
|
|
||||||
- **JSTOR**: Academic journal database
|
|
||||||
- **PubMed**: Medical and scientific research
|
|
||||||
- **IEEE Xplore**: Technical and engineering research
|
|
||||||
|
|
||||||
### 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
|
**Value**: Delivers competitive intelligence and keyword strategy in one research pass
|
||||||
- **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
|
|
||||||
|
|
||||||
#### News and Media
|
## Performance & Caching
|
||||||
- **Reuters**: International news and analysis
|
|
||||||
- **Bloomberg**: Business and financial news
|
### Intelligent Caching System
|
||||||
- **TechCrunch**: Technology news and analysis
|
|
||||||
- **Harvard Business Review**: Business insights and analysis
|
Phase 1 implements a dual-layer caching strategy to optimize performance and reduce costs.
|
||||||
- **MIT Technology Review**: Technology and innovation news
|
|
||||||
|
#### 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
|
## Best Practices
|
||||||
|
|
||||||
### Research Quality
|
### Effective Research Setup
|
||||||
|
|
||||||
#### Source Selection
|
#### Keyword Strategy
|
||||||
1. **Authority**: Choose authoritative and credible sources
|
1. **Be Specific**: Use 3-5 focused keywords rather than broad topics
|
||||||
2. **Recency**: Prefer recent and up-to-date information
|
2. **Industry Context**: Always specify industry for better context
|
||||||
3. **Relevance**: Ensure sources are relevant to your topic
|
3. **Audience Definition**: Define target audience clearly for tailored research
|
||||||
4. **Diversity**: Include diverse perspectives and sources
|
4. **Topic Clarity**: Provide a clear, concise topic description
|
||||||
5. **Verification**: Cross-reference information across sources
|
5. **Word Count Target**: Set realistic word count goals (1000-3000 words optimal)
|
||||||
|
|
||||||
#### Information Processing
|
#### Research Quality Optimization
|
||||||
1. **Accuracy**: Verify all facts and claims
|
1. **Review Sources**: Always review the returned sources for credibility
|
||||||
2. **Context**: Understand context and interpretation
|
2. **Use Content Angles**: Leverage AI-generated angles for unique positioning
|
||||||
3. **Bias Awareness**: Be aware of potential bias
|
3. **Explore Competitor Gaps**: Focus on content gaps for competitive advantage
|
||||||
4. **Completeness**: Ensure comprehensive coverage
|
4. **Keyword Variety**: Review all keyword types (primary, secondary, long-tail)
|
||||||
5. **Quality**: Maintain high quality standards
|
5. **Leverage Caching**: Reuse research for related topics to save time and cost
|
||||||
|
|
||||||
### Content Integration
|
### Research-to-Content Pipeline
|
||||||
|
|
||||||
#### Research Application
|
#### Phase 1 to Phase 2 Transition
|
||||||
1. **Relevance**: Use research that's relevant to your audience
|
1. **Validate Research**: Ensure research has 10+ credible sources before proceeding
|
||||||
2. **Balance**: Balance different perspectives and opinions
|
2. **Review Angles**: Select compelling content angles for outline inspiration
|
||||||
3. **Clarity**: Present research findings clearly
|
3. **Check Keywords**: Verify keyword analysis aligns with your SEO goals
|
||||||
4. **Attribution**: Properly attribute all sources
|
4. **Analyze Gaps**: Use competitor analysis to inform unique content positioning
|
||||||
5. **Value**: Add value through research insights
|
5. **Source Quality**: Confirm grounding metadata shows high credibility scores (0.8+)
|
||||||
|
|
||||||
#### Quality Assurance
|
#### Research Output Utilization
|
||||||
1. **Fact Checking**: Verify all facts and claims
|
1. **Source Mapping**: Use sources strategically across different sections
|
||||||
2. **Source Review**: Review and validate all sources
|
2. **Keyword Integration**: Naturally integrate primary and secondary keywords
|
||||||
3. **Expert Input**: Seek expert input when needed
|
3. **Angles to Sections**: Transform content angles into distinct content sections
|
||||||
4. **Peer Review**: Get peer review of research quality
|
4. **Gaps to Value**: Convert content gaps into unique selling propositions
|
||||||
5. **Continuous Improvement**: Continuously improve research process
|
5. **Trend Integration**: Weave current trends naturally throughout content
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues & Solutions
|
||||||
|
|
||||||
#### Research Quality
|
#### Low-Quality Research Results
|
||||||
- **Insufficient Sources**: Add more diverse sources
|
**Problem**: Research returns fewer than 10 sources or low credibility scores
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Technical Issues
|
**Solutions**:
|
||||||
- **API Connectivity**: Resolve API connection issues
|
- **Refine Keywords**: Use more specific, focused keywords
|
||||||
- **Data Processing**: Fix data processing problems
|
- **Expand Topic**: Broaden topic slightly to increase source pool
|
||||||
- **Source Access**: Resolve source access issues
|
- **Adjust Industry**: Ensure industry classification is accurate
|
||||||
- **Performance Issues**: Address performance concerns
|
- **Check Cache**: Clear cache if you're getting stale results
|
||||||
- **Integration Problems**: Fix integration issues
|
- **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
|
### Getting Help
|
||||||
|
|
||||||
#### Support Resources
|
#### Support Resources
|
||||||
- **Documentation**: Review research integration documentation
|
- **Workflow Guide**: [Complete 6-phase walkthrough](workflow-guide.md)
|
||||||
- **Tutorials**: Watch research feature tutorials
|
- **API Reference**: [Research API endpoints](api-reference.md)
|
||||||
- **Best Practices**: Follow research best practices
|
- **Implementation Spec**: [Technical architecture](implementation-spec.md)
|
||||||
- **Community**: Join user community discussions
|
- **Best Practices**: [Advanced optimization tips](../../guides/best-practices.md)
|
||||||
- **Support**: Contact technical support
|
|
||||||
|
|
||||||
#### Optimization Tips
|
#### Performance Optimization
|
||||||
- **Settings Review**: Regularly review research settings
|
- **Use Caching**: Leverage intelligent caching for repeat research
|
||||||
- **Source Management**: Maintain source quality and diversity
|
- **Keyword Precision**: More specific keywords yield better results
|
||||||
- **Quality Monitoring**: Monitor research quality continuously
|
- **Industry Context**: Always provide industry for better data quality
|
||||||
- **Performance Tracking**: Track research performance metrics
|
- **Monitor Progress**: Review progress messages for efficiency insights
|
||||||
- **Continuous Improvement**: Continuously improve research process
|
- **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
|
### Key Benefits
|
||||||
|
|
||||||
- **Search Optimization**: Optimize content for search engines
|
#### Phase 4: SEO Analysis
|
||||||
- **Keyword Analysis**: Analyze and optimize keyword usage
|
- **Multi-Dimensional Scoring**: Comprehensive SEO evaluation across 5 key categories
|
||||||
- **Readability Enhancement**: Improve content readability and user experience
|
- **Actionable Recommendations**: Priority-ranked improvement suggestions with specific fixes
|
||||||
- **Technical SEO**: Ensure proper technical SEO implementation
|
- **AI-Powered Refinement**: One-click "Apply Recommendations" for instant optimization
|
||||||
- **Performance Insights**: Track and improve SEO performance
|
- **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
|
#### Initial Assessment
|
||||||
- **Content Structure**: Analyze heading hierarchy and content organization
|
- **Content Structure**: Analyzes heading hierarchy, paragraph distribution, list usage
|
||||||
- **Keyword Density**: Check keyword usage and density
|
- **Keyword Distribution**: Maps keyword density and placement across sections
|
||||||
- **Content Length**: Evaluate content length and depth
|
- **Readability Metrics**: Calculates Flesch Reading Ease, sentence length, complexity
|
||||||
- **Readability**: Assess content readability and user experience
|
- **Quality Indicators**: Evaluates depth, engagement potential, value delivery
|
||||||
- **Technical Elements**: Check technical SEO elements
|
- **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
|
```json
|
||||||
{
|
{
|
||||||
"content": "Your blog post content here...",
|
"@context": "https://schema.org",
|
||||||
"target_keywords": ["primary keyword", "secondary keyword"],
|
"@type": "BlogPosting",
|
||||||
"competitor_urls": ["https://competitor1.com", "https://competitor2.com"],
|
"headline": "SEO-optimized title",
|
||||||
"analysis_depth": "comprehensive",
|
"description": "Meta description",
|
||||||
"optimization_goals": ["rankings", "traffic", "engagement"]
|
"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
|
### Multi-Format Export
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Secondary Keywords
|
Phase 5 outputs metadata in multiple formats for different platforms:
|
||||||
- **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
|
|
||||||
|
|
||||||
### 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
|
#### JSON-LD Structured Data
|
||||||
- **Heading Hierarchy**: Check H1, H2, H3 structure
|
Ready-to-paste structured data for search engines
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Readability Assessment
|
#### WordPress Export
|
||||||
- **Reading Level**: Assess content reading level
|
WordPress-specific format with Yoast SEO compatibility
|
||||||
- **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
|
|
||||||
|
|
||||||
## SEO Analysis Features
|
#### Wix Integration
|
||||||
|
Direct Wix blog API format for seamless publishing
|
||||||
### 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
|
|
||||||
|
|
||||||
## Analysis Results
|
## Analysis Results
|
||||||
|
|
||||||
### SEO Score
|
### Phase 4 Output Structure
|
||||||
|
|
||||||
#### Overall Score
|
Phase 4 returns comprehensive analysis results:
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Score Breakdown
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"overall_score": 85,
|
"overall_score": 82,
|
||||||
"keyword_score": 90,
|
"grade": "Good",
|
||||||
"content_score": 80,
|
"category_scores": {
|
||||||
"technical_score": 85,
|
"structure": 85,
|
||||||
"readability_score": 88,
|
"keywords": 88,
|
||||||
"recommendations": [
|
"readability": 78,
|
||||||
"Improve meta description length",
|
"quality": 80,
|
||||||
"Add more internal links",
|
"headings": 84
|
||||||
"Optimize image alt text"
|
},
|
||||||
]
|
"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
|
### For Technical Content Writers
|
||||||
- **Keyword Density**: Adjust keyword density for optimal results
|
**Scenario**: Creating a technical deep-dive on "React Server Components"
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Content Improvement
|
**Phase 4 Delivers**:
|
||||||
- **Heading Structure**: Improve heading hierarchy
|
- Structure score analysis: Identifies need for more code examples in H3 sections
|
||||||
- **Paragraph Length**: Optimize paragraph length
|
- Readability assessment: Detects overly complex technical jargon
|
||||||
- **Content Flow**: Enhance content flow and organization
|
- Keyword optimization: Suggests semantic keywords like "React SSR" and "Next.js 13"
|
||||||
- **Readability**: Improve content readability
|
- Actionable fix: "Add 'why it matters' explanations for React Server Component concepts"
|
||||||
- **Engagement**: Increase content engagement
|
|
||||||
|
|
||||||
#### Technical Optimization
|
**Phase 5 Delivers**:
|
||||||
- **Meta Tags**: Optimize meta tags and descriptions
|
- SEO title: "React Server Components Explained: Complete 2025 Guide"
|
||||||
- **Image Optimization**: Improve image alt text and optimization
|
- Meta description: Includes CTA like "Master RSC implementation with practical examples"
|
||||||
- **Internal Linking**: Add strategic internal links
|
- JSON-LD: Code schema markup for search engine code indexing
|
||||||
- **URL Structure**: Optimize URL structure
|
- Social tags: #React #WebDevelopment #Programming
|
||||||
- **Schema Markup**: Implement structured data
|
|
||||||
|
|
||||||
## 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
|
**Phase 4 Delivers**:
|
||||||
- **Content Length**: Compare content length with competitors
|
- Quality score: Identifies missing CTA elements in conclusion
|
||||||
- **Keyword Usage**: Analyze competitor keyword strategies
|
- Readability: Highlights need to simplify business jargon
|
||||||
- **Content Structure**: Compare content organization
|
- Keyword gaps: Discovers missing long-tail "online course pricing strategy"
|
||||||
- **Readability**: Assess competitor content readability
|
- High-priority fix: "Add specific revenue examples to build credibility"
|
||||||
- **Engagement**: Compare engagement potential
|
|
||||||
|
|
||||||
#### SEO Performance
|
**Phase 5 Delivers**:
|
||||||
- **Search Rankings**: Compare search engine rankings
|
- SEO title: "Start Online Course Business: Ultimate 2025 Guide" (56 chars)
|
||||||
- **Traffic Analysis**: Analyze competitor traffic patterns
|
- Social hashtags: #OnlineCourses #PassiveIncome #Entrepreneurship
|
||||||
- **Backlink Profile**: Compare backlink strategies
|
- Schema.org: EducationalCourse schema for course-related rich snippets
|
||||||
- **Social Signals**: Analyze social media performance
|
- Reading time: "15 minutes" for appropriate audience expectation
|
||||||
- **Content Gaps**: Identify content opportunities
|
|
||||||
|
|
||||||
### Gap Analysis
|
**Value**: Professional SEO without hiring expensive consultants
|
||||||
|
|
||||||
#### Content Opportunities
|
### For Digital Marketing & SEO Professionals
|
||||||
- **Missing Topics**: Identify topics competitors haven't covered
|
**Scenario**: Strategy content on "Local SEO for Small Businesses"
|
||||||
- **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
|
|
||||||
|
|
||||||
#### Competitive Advantages
|
**Phase 4 Delivers**:
|
||||||
- **Unique Angles**: Develop unique content angles
|
- Comprehensive scoring across all 5 categories with detailed breakdown
|
||||||
- **Expertise Showcase**: Highlight unique expertise
|
- Competitor analysis integration from Phase 1 research
|
||||||
- **Better Coverage**: Provide more comprehensive coverage
|
- High-priority recommendations: "Missing Google Business Profile optimization section"
|
||||||
- **Improved Quality**: Create higher quality content
|
- Metrics: Keyword density at 0.9%, target 1.5-2% for competitive keywords
|
||||||
- **Enhanced User Experience**: Improve user experience
|
|
||||||
|
|
||||||
## 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
|
**Value**: Enterprise-grade SEO optimization with detailed analytics
|
||||||
|
|
||||||
#### 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
|
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### Content Optimization
|
### Phase 4: SEO Analysis Best Practices
|
||||||
|
|
||||||
#### Keyword Strategy
|
#### Pre-Analysis Preparation
|
||||||
1. **Primary Focus**: Focus on one primary keyword per page
|
1. **Complete Content**: Ensure all sections are finalized before analysis
|
||||||
2. **Natural Integration**: Integrate keywords naturally
|
2. **Research Integration**: Verify Phase 1 research data includes keywords
|
||||||
3. **Semantic Keywords**: Use semantically related terms
|
3. **Word Count**: Target 1000-3000 words for optimal SEO analysis
|
||||||
4. **Long-Tail Keywords**: Target specific, long-tail phrases
|
4. **Structure Review**: Confirm proper heading hierarchy (H1, H2, H3)
|
||||||
5. **User Intent**: Match keywords to user search intent
|
5. **Content Quality**: Ensure content is factually accurate and complete
|
||||||
|
|
||||||
#### Content Quality
|
#### Using "Apply Recommendations"
|
||||||
1. **Original Content**: Create original, unique content
|
1. **Review First**: Always review recommendations before applying
|
||||||
2. **Comprehensive Coverage**: Provide comprehensive topic coverage
|
2. **Selective Application**: Consider applying high-priority fixes first
|
||||||
3. **Expert Authority**: Demonstrate expertise and authority
|
3. **Edit After**: Manually refine AI-applied changes for your voice
|
||||||
4. **User Value**: Provide clear value to users
|
4. **Preserve Intent**: Verify AI preserved your original meaning
|
||||||
5. **Engagement**: Create engaging, shareable content
|
5. **Re-Analyze**: Run Phase 4 again after applying to track improvement
|
||||||
|
|
||||||
### Technical SEO
|
### Phase 5: Metadata Generation Best Practices
|
||||||
|
|
||||||
#### On-Page Optimization
|
#### Metadata Optimization
|
||||||
1. **Title Tags**: Create compelling, keyword-rich titles
|
1. **Title Length**: Keep SEO titles to 50-60 characters for SERP display
|
||||||
2. **Meta Descriptions**: Write engaging meta descriptions
|
2. **Meta Descriptions**: Write 150-160 character descriptions with CTA in first 120 chars
|
||||||
3. **Heading Structure**: Use proper heading hierarchy
|
3. **Keyword Placement**: Front-load primary keyword in title and first 120 chars of description
|
||||||
4. **Internal Linking**: Implement strategic internal linking
|
4. **Uniqueness**: Ensure metadata is unique for each blog post
|
||||||
5. **Image Optimization**: Optimize images with alt text
|
5. **Brand Consistency**: Include brand name where appropriate without exceeding length limits
|
||||||
|
|
||||||
#### Site Performance
|
#### Social Media Optimization
|
||||||
1. **Page Speed**: Optimize page loading speed
|
1. **Image Planning**: Prepare 1200x630px images for Open Graph sharing
|
||||||
2. **Mobile Optimization**: Ensure mobile-friendly design
|
2. **Twitter Cards**: Ensure Twitter Card images are 1200x600px minimum
|
||||||
3. **SSL Certificate**: Use HTTPS for security
|
3. **Hashtag Strategy**: Mix industry-specific, trending, and branded hashtags
|
||||||
4. **Clean URLs**: Use clean, descriptive URLs
|
4. **Platform-Specific**: Review Open Graph vs Twitter Card differences
|
||||||
5. **Schema Markup**: Implement structured data
|
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
|
#### Performance Optimization
|
||||||
- **Automatic Optimization**: AI-powered content optimization
|
1. **Cache Utilization**: Leverage research caching from Phase 1 for related topics
|
||||||
- **Keyword Suggestions**: Intelligent keyword recommendations
|
2. **Batch Analysis**: Analyze multiple blog drafts in one session to improve learning
|
||||||
- **Content Improvement**: Automated content improvement suggestions
|
3. **Score Tracking**: Monitor SEO score trends across multiple posts
|
||||||
- **Readability Enhancement**: AI-powered readability improvements
|
4. **A/B Testing**: Test different metadata variations for CTR optimization
|
||||||
- **Engagement Optimization**: Optimize for user engagement
|
5. **Analytics Integration**: Connect to Google Analytics/Search Console post-publish
|
||||||
|
|
||||||
#### 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
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues & Solutions
|
||||||
|
|
||||||
#### SEO Analysis Problems
|
#### Low SEO Scores (< 70)
|
||||||
- **Low SEO Scores**: Address low SEO performance
|
**Problem**: Overall SEO score below 70 or grade showing "Needs Improvement"
|
||||||
- **Keyword Issues**: Resolve keyword optimization problems
|
|
||||||
- **Content Quality**: Improve content quality and structure
|
|
||||||
- **Technical Issues**: Fix technical SEO problems
|
|
||||||
- **Performance Issues**: Address performance concerns
|
|
||||||
|
|
||||||
#### Optimization Challenges
|
**Solutions**:
|
||||||
- **Keyword Overuse**: Avoid keyword stuffing
|
- **Check Category Scores**: Review individual category breakdowns to identify weak areas
|
||||||
- **Content Duplication**: Prevent duplicate content issues
|
- **Apply High-Priority Recommendations**: Focus on critical fixes first
|
||||||
- **Technical Errors**: Fix technical SEO errors
|
- **Verify Content Length**: Ensure 1000+ words for comprehensive analysis
|
||||||
- **Performance Problems**: Resolve performance issues
|
- **Review Heading Structure**: Confirm proper H1/H2/H3 hierarchy
|
||||||
- **Competition Analysis**: Improve competitive analysis
|
- **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
|
### Getting Help
|
||||||
|
|
||||||
#### Support Resources
|
#### Support Resources
|
||||||
- **Documentation**: Review SEO analysis documentation
|
- **[Workflow Guide](workflow-guide.md)**: Complete 6-phase walkthrough
|
||||||
- **Tutorials**: Watch SEO optimization tutorials
|
- **[Blog Writer Overview](overview.md)**: Overview of all phases
|
||||||
- **Best Practices**: Follow SEO best practices
|
- **[API Reference](api-reference.md)**: Technical API documentation
|
||||||
- **Community**: Join user community discussions
|
- **[Best Practices](../../guides/best-practices.md)**: Advanced optimization tips
|
||||||
- **Support**: Contact technical support
|
|
||||||
|
|
||||||
#### Optimization Tips
|
#### Performance Tips
|
||||||
- **Regular Analysis**: Perform regular SEO analysis
|
- **Batch Processing**: Analyze multiple drafts in one session for efficiency
|
||||||
- **Continuous Improvement**: Continuously improve SEO performance
|
- **Cache Benefits**: Reuse research from Phase 1 to speed up workflow
|
||||||
- **Performance Monitoring**: Monitor SEO performance metrics
|
- **Score Tracking**: Monitor SEO improvements across multiple blog posts
|
||||||
- **Competitive Analysis**: Regular competitive analysis
|
- **Metadata Testing**: Use Facebook Debugger and Twitter Card Validator
|
||||||
- **Quality Assurance**: Maintain high quality standards
|
- **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
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Start: Keywords & Topic] --> B[Phase 1: Research & Discovery]
|
A[Start: Keywords & Topic] --> B[Phase 1: Research & Strategy]
|
||||||
B --> C[Phase 2: Outline Generation]
|
B --> C[Phase 2: Intelligent Outline]
|
||||||
C --> D[Phase 3: Content Generation]
|
C --> D[Phase 3: Content Generation]
|
||||||
D --> E[Phase 4: SEO Analysis]
|
D --> E[Phase 4: SEO Analysis]
|
||||||
E --> F[Phase 5: Quality Assurance]
|
E --> F[Phase 5: SEO Metadata]
|
||||||
F --> G[Phase 6: Publishing]
|
F --> G[Phase 6: Publish & Distribute]
|
||||||
|
|
||||||
B --> B1[Web Search & Source Collection]
|
B --> B1[Google Search Grounding]
|
||||||
B --> B2[Competitor Analysis]
|
B --> B2[Competitor Analysis]
|
||||||
B --> B3[Research Caching]
|
B --> B3[Research Caching]
|
||||||
|
|
||||||
C --> C1[Content Structure Planning]
|
C --> C1[AI Outline Generation]
|
||||||
C --> C2[Section Definition]
|
C --> C2[Source Mapping]
|
||||||
C --> C3[Source Mapping]
|
C --> C3[Title Generation]
|
||||||
|
|
||||||
D --> D1[Section-by-Section Writing]
|
D --> D1[Section-by-Section Writing]
|
||||||
D --> D2[Citation Integration]
|
D --> D2[Context Memory]
|
||||||
D --> D3[Continuity Tracking]
|
D --> D3[Flow Analysis]
|
||||||
|
|
||||||
E --> E1[SEO Scoring]
|
E --> E1[SEO Scoring]
|
||||||
E --> E2[Keyword Analysis]
|
E --> E2[Actionable Recommendations]
|
||||||
E --> E3[Readability Assessment]
|
E --> E3[AI-Powered Refinement]
|
||||||
|
|
||||||
F --> F1[Fact Verification]
|
F --> F1[Comprehensive Metadata]
|
||||||
F --> F2[Hallucination Detection]
|
F --> F2[Open Graph & Twitter Cards]
|
||||||
F --> F3[Quality Scoring]
|
F --> F3[Schema.org Markup]
|
||||||
|
|
||||||
G --> G1[Platform Integration]
|
G --> G1[Multi-Platform Publishing]
|
||||||
G --> G2[Metadata Generation]
|
G --> G2[Scheduling]
|
||||||
G --> G3[Content Publishing]
|
G --> G3[Version Management]
|
||||||
|
|
||||||
style A fill:#e3f2fd
|
style A fill:#e3f2fd
|
||||||
style B fill:#e8f5e8
|
style B fill:#e8f5e8
|
||||||
@@ -58,40 +58,40 @@ gantt
|
|||||||
dateFormat X
|
dateFormat X
|
||||||
axisFormat %M:%S
|
axisFormat %M:%S
|
||||||
|
|
||||||
section Research
|
section Phase 1 Research
|
||||||
Keyword Analysis :0, 10
|
Keyword Analysis :0, 10
|
||||||
Web Search :10, 30
|
Google Search :10, 40
|
||||||
Source Collection :20, 40
|
Source Extraction :30, 50
|
||||||
Competitor Analysis :30, 50
|
Competitor Analysis :40, 60
|
||||||
Research Caching :40, 60
|
Research Caching :50, 60
|
||||||
|
|
||||||
section Outline
|
section Phase 2 Outline
|
||||||
Structure Planning :60, 70
|
AI Structure Planning :60, 80
|
||||||
Section Definition :70, 80
|
Section Definition :75, 90
|
||||||
Source Mapping :80, 90
|
Source Mapping :85, 100
|
||||||
Title Generation :90, 100
|
Title Generation :95, 110
|
||||||
|
|
||||||
section Content
|
section Phase 3 Content
|
||||||
Section 1 Writing :100, 120
|
Section 1 Writing :110, 140
|
||||||
Section 2 Writing :120, 140
|
Section 2 Writing :130, 160
|
||||||
Section 3 Writing :140, 160
|
Section 3 Writing :150, 180
|
||||||
Citation Integration :160, 170
|
Context Continuity :170, 200
|
||||||
|
|
||||||
section SEO
|
section Phase 4 SEO
|
||||||
Structure Analysis :170, 180
|
Parallel Analysis :200, 215
|
||||||
Keyword Analysis :180, 190
|
AI Scoring :210, 230
|
||||||
Readability Check :190, 200
|
Recommendations :220, 235
|
||||||
SEO Scoring :200, 210
|
Apply Refinement :230, 250
|
||||||
|
|
||||||
section Quality
|
section Phase 5 Metadata
|
||||||
Fact Verification :210, 220
|
Core Metadata :250, 265
|
||||||
Hallucination Check :220, 230
|
Social Tags :260, 275
|
||||||
Quality Scoring :230, 240
|
Schema Markup :270, 285
|
||||||
|
|
||||||
section Publishing
|
section Phase 6 Publish
|
||||||
Platform Integration :240, 250
|
Platform Setup :285, 295
|
||||||
Metadata Generation :250, 260
|
Content Publishing :290, 310
|
||||||
Content Publishing :260, 270
|
Verification :305, 320
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📋 Prerequisites
|
## 📋 Prerequisites
|
||||||
@@ -104,7 +104,7 @@ Before starting, ensure you have:
|
|||||||
- **Content Goals**: Defined objectives for your blog post
|
- **Content Goals**: Defined objectives for your blog post
|
||||||
- **Word Count Target**: Desired length (typically 1000-3000 words)
|
- **Word Count Target**: Desired length (typically 1000-3000 words)
|
||||||
|
|
||||||
## 🔍 Phase 1: Research & Discovery
|
## 🔍 Phase 1: Research & Strategy
|
||||||
|
|
||||||
### Step 1: Initiate Research
|
### Step 1: Initiate Research
|
||||||
|
|
||||||
@@ -170,7 +170,7 @@ Before starting, ensure you have:
|
|||||||
- ✅ Relevant to your target audience
|
- ✅ Relevant to your target audience
|
||||||
- ✅ Covers multiple aspects of your topic
|
- ✅ Covers multiple aspects of your topic
|
||||||
|
|
||||||
## 📝 Phase 2: Outline Generation
|
## 📝 Phase 2: Intelligent Outline
|
||||||
|
|
||||||
### Step 1: Generate Outline
|
### Step 1: Generate Outline
|
||||||
|
|
||||||
@@ -235,6 +235,31 @@ Before starting, ensure you have:
|
|||||||
- **Add Sections**: Include missing content areas
|
- **Add Sections**: Include missing content areas
|
||||||
- **Improve SEO**: Better keyword distribution
|
- **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
|
## ✍️ Phase 3: Content Generation
|
||||||
|
|
||||||
### Step 1: Generate Section Content
|
### Step 1: Generate Section Content
|
||||||
@@ -311,7 +336,71 @@ Repeat the process for each outline section:
|
|||||||
- Use continuity metrics to ensure flow
|
- Use continuity metrics to ensure flow
|
||||||
- Adjust tone and style as needed
|
- 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
|
### Step 1: Perform SEO Analysis
|
||||||
|
|
||||||
@@ -356,7 +445,21 @@ Repeat the process for each outline section:
|
|||||||
- ✅ Proper heading structure
|
- ✅ Proper heading structure
|
||||||
- ✅ Actionable recommendations
|
- ✅ 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`
|
**Endpoint**: `POST /api/blog/seo/metadata`
|
||||||
|
|
||||||
@@ -373,66 +476,50 @@ Repeat the process for each outline section:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Generated Metadata**:
|
**What Happens** (First AI Call):
|
||||||
- **SEO Title**: Optimized for search engines
|
1. **SEO Title**: Optimized for search engines (50-60 chars)
|
||||||
- **Meta Description**: Compelling 155-character description
|
2. **Meta Description**: Compelling description with CTA (150-160 chars)
|
||||||
- **URL Slug**: SEO-friendly URL structure
|
3. **URL Slug**: Clean, hyphenated, keyword-rich (3-5 words)
|
||||||
- **Tags & Categories**: Relevant content classification
|
4. **Blog Tags**: Mix of primary, semantic, and long-tail keywords (5-8)
|
||||||
- **Social Media Tags**: Open Graph and Twitter Card data
|
5. **Blog Categories**: Industry-standard classification (2-3)
|
||||||
- **JSON-LD Schema**: Structured data for search engines
|
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**:
|
**Generated Metadata Output**:
|
||||||
```json
|
- **Core Elements**: Title, description, URL slug, tags, categories
|
||||||
{
|
- **Social Optimization**: Open Graph and Twitter Card tags
|
||||||
"content": "Complete blog content here...",
|
- **Structured Data**: Article schema with author, dates, organization
|
||||||
"sources": [
|
- **Platform Formats**: Copy-ready for WordPress, Wix, custom
|
||||||
"https://example.com/source1",
|
|
||||||
"https://example.com/source2"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**What Happens**:
|
**Expected Duration**: 10-15 seconds
|
||||||
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**: 15-25 seconds
|
### Step 3: Review & Export Metadata
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|
||||||
**Quality Checklist**:
|
**Quality Checklist**:
|
||||||
- ✅ High factual accuracy (90%+)
|
- ✅ SEO title is 50-60 characters with primary keyword
|
||||||
- ✅ Good source coverage (80%+)
|
- ✅ Meta description includes CTA in first 120 chars
|
||||||
- ✅ Quality score above 85
|
- ✅ URL slug is clean, readable, and keyword-rich
|
||||||
- ✅ No major factual errors
|
- ✅ Tags and categories are relevant and varied
|
||||||
- ✅ Clear improvement suggestions
|
- ✅ 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`
|
## 🚀 Phase 6: Publish & Distribute
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
### Step 1: Prepare for Publishing
|
### 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 { CopilotSidebar } from '@copilotkit/react-ui';
|
||||||
|
import { useCopilotChatHeadless_c } from '@copilotkit/react-core';
|
||||||
import { useCopilotAction } from '@copilotkit/react-core';
|
import { useCopilotAction } from '@copilotkit/react-core';
|
||||||
import '@copilotkit/react-ui/styles.css';
|
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 { useOutlinePolling, useMediumGenerationPolling, useResearchPolling, useRewritePolling } from '../../hooks/usePolling';
|
||||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||||
@@ -26,10 +29,16 @@ import OutlineRefiner from './OutlineRefiner';
|
|||||||
import { SEOProcessor } from './SEO';
|
import { SEOProcessor } from './SEO';
|
||||||
import BlogWriterLanding from './BlogWriterLanding';
|
import BlogWriterLanding from './BlogWriterLanding';
|
||||||
import { OutlineProgressModal } from './OutlineProgressModal';
|
import { OutlineProgressModal } from './OutlineProgressModal';
|
||||||
|
import TaskProgressModals from './BlogWriterUtils/TaskProgressModals';
|
||||||
import OutlineFeedbackForm from './OutlineFeedbackForm';
|
import OutlineFeedbackForm from './OutlineFeedbackForm';
|
||||||
import { BlogEditor } from './WYSIWYG';
|
import { BlogEditor } from './WYSIWYG';
|
||||||
import { SEOAnalysisModal } from './SEOAnalysisModal';
|
import { SEOAnalysisModal } from './SEOAnalysisModal';
|
||||||
import { SEOMetadataModal } from './SEOMetadataModal';
|
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
|
// Type assertion for CopilotKit action
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
const useCopilotActionTyped = useCopilotAction as any;
|
||||||
@@ -59,6 +68,7 @@ export const BlogWriter: React.FC = () => {
|
|||||||
flowAnalysisResults,
|
flowAnalysisResults,
|
||||||
setOutline,
|
setOutline,
|
||||||
setTitleOptions,
|
setTitleOptions,
|
||||||
|
setSelectedTitle,
|
||||||
setSections,
|
setSections,
|
||||||
setSeoAnalysis,
|
setSeoAnalysis,
|
||||||
setGenMode,
|
setGenMode,
|
||||||
@@ -79,6 +89,227 @@ export const BlogWriter: React.FC = () => {
|
|||||||
handleContentSave
|
handleContentSave
|
||||||
} = useBlogWriterState();
|
} = 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
|
// Custom hooks for complex functionality
|
||||||
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
|
const { buildFullMarkdown, buildUpdatedMarkdownForClaim, applyClaimFix } = useClaimFixer(
|
||||||
outline,
|
outline,
|
||||||
@@ -139,28 +370,63 @@ export const BlogWriter: React.FC = () => {
|
|||||||
onError: (err) => console.error('Rewrite failed:', err)
|
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
|
// Add minimum display time for modal
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
|
const [modalStartTime, setModalStartTime] = useState<number | null>(null);
|
||||||
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
|
const [isMediumGenerationStarting, setIsMediumGenerationStarting] = useState(false);
|
||||||
const [showOutlineModal, setShowOutlineModal] = useState(false);
|
const [showOutlineModal, setShowOutlineModal] = useState(false);
|
||||||
|
|
||||||
// SEO Analysis Modal state
|
const suggestions = useSuggestions({
|
||||||
const [isSEOAnalysisModalOpen, setIsSEOAnalysisModalOpen] = useState(false);
|
research,
|
||||||
const [isSEOMetadataModalOpen, setIsSEOMetadataModalOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
if ((mediumPolling.isPolling || rewritePolling.isPolling || isMediumGenerationStarting) && !showModal) {
|
||||||
@@ -214,96 +480,73 @@ export const BlogWriter: React.FC = () => {
|
|||||||
progressCount: mediumPolling.progressMessages.length
|
progressCount: mediumPolling.progressMessages.length
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug SEO modal state
|
// Log critical state changes only (reduce noise)
|
||||||
console.log('🔍 SEO Analysis Modal state:', {
|
const lastPhaseRef = useRef<string>('');
|
||||||
isSEOAnalysisModalOpen,
|
const lastSeoOpenRef = useRef<boolean>(false);
|
||||||
hasResearch: !!research,
|
const lastSectionsLenRef = useRef<number>(0);
|
||||||
hasContent: !!sections && Object.keys(sections).length > 0,
|
|
||||||
researchKeys: research ? Object.keys(research) : [],
|
|
||||||
sectionsKeys: sections ? Object.keys(sections) : []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug action registration
|
useEffect(() => {
|
||||||
console.log('📋 CopilotKit Actions Registered:', ['confirmBlogContent', 'analyzeSEO']);
|
if (currentPhase !== lastPhaseRef.current) {
|
||||||
|
debug.log('[BlogWriter] Phase changed', { currentPhase });
|
||||||
// Copilot action for confirming blog content
|
lastPhaseRef.current = currentPhase;
|
||||||
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.";
|
|
||||||
}
|
}
|
||||||
});
|
}, [currentPhase]);
|
||||||
|
|
||||||
// Copilot action for running SEO analysis
|
useEffect(() => {
|
||||||
useCopilotActionTyped({
|
const open = isSEOAnalysisModalOpen;
|
||||||
name: "analyzeSEO",
|
if (open !== lastSeoOpenRef.current) {
|
||||||
description: "Analyze the blog content for SEO optimization and provide detailed recommendations",
|
debug.log('[BlogWriter] SEO modal', { isOpen: open });
|
||||||
parameters: [],
|
lastSeoOpenRef.current = open;
|
||||||
handler: async () => {
|
}
|
||||||
console.log('🚀 SEO Analysis Action Triggered!');
|
}, [isSEOAnalysisModalOpen]);
|
||||||
console.log('Current modal state before:', isSEOAnalysisModalOpen);
|
|
||||||
console.log('Sections available:', !!sections && Object.keys(sections).length > 0);
|
useEffect(() => {
|
||||||
console.log('Research data available:', !!research && !!research.keyword_analysis);
|
const len = Object.keys(sections || {}).length;
|
||||||
|
if (len !== lastSectionsLenRef.current) {
|
||||||
// Check if we have content to analyze
|
debug.log('[BlogWriter] Sections updated', { count: len });
|
||||||
if (!sections || Object.keys(sections).length === 0) {
|
lastSectionsLenRef.current = len;
|
||||||
console.log('❌ No content available for SEO analysis');
|
}
|
||||||
return "No blog content available for SEO analysis. Please generate content first.";
|
}, [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;
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// Check if we have research data
|
console.error('Failed to push Copilot suggestions after SEO apply:', e);
|
||||||
if (!research || !research.keyword_analysis) {
|
}
|
||||||
console.log('❌ No research data available for SEO analysis');
|
}, [seoAnalysis, seoRecommendationsApplied, suggestionsJson, suggestionsPayload]);
|
||||||
return "Research data is required for SEO analysis. Please run research first.";
|
|
||||||
}
|
const confirmBlogContentCb = useCallback(() => {
|
||||||
|
debug.log('[BlogWriter] Blog content confirmed by user');
|
||||||
// Open SEO analysis modal
|
setContentConfirmed(true);
|
||||||
console.log('✅ All checks passed, opening SEO analysis modal');
|
resetUserSelection();
|
||||||
|
setSeoRecommendationsApplied(false);
|
||||||
|
navigateToPhase('seo');
|
||||||
|
setTimeout(() => {
|
||||||
setIsSEOAnalysisModalOpen(true);
|
setIsSEOAnalysisModalOpen(true);
|
||||||
console.log('Modal state set to true');
|
debug.log('[BlogWriter] SEO modal opened (confirm→direct)');
|
||||||
|
}, 0);
|
||||||
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
|
return "✅ Blog content has been confirmed! Running SEO analysis now.";
|
||||||
}
|
}, [setContentConfirmed, resetUserSelection, navigateToPhase, setIsSEOAnalysisModalOpen]);
|
||||||
});
|
|
||||||
|
|
||||||
// Generate SEO Metadata Action
|
useBlogWriterCopilotActions({
|
||||||
useCopilotActionTyped({
|
isSEOAnalysisModalOpen,
|
||||||
name: "generateSEOMetadata",
|
lastSEOModalOpenRef,
|
||||||
description: "Generate comprehensive SEO metadata including titles, descriptions, Open Graph tags, Twitter cards, and structured data",
|
runSEOAnalysisDirect,
|
||||||
parameters: [
|
confirmBlogContent: confirmBlogContentCb,
|
||||||
{
|
sections,
|
||||||
name: "title",
|
research,
|
||||||
type: "string",
|
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||||
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.";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -366,6 +609,7 @@ export const BlogWriter: React.FC = () => {
|
|||||||
|
|
||||||
{/* New extracted functionality components */}
|
{/* New extracted functionality components */}
|
||||||
<OutlineGenerator
|
<OutlineGenerator
|
||||||
|
ref={outlineGenRef}
|
||||||
research={research}
|
research={research}
|
||||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||||
@@ -395,241 +639,70 @@ export const BlogWriter: React.FC = () => {
|
|||||||
{!research ? (
|
{!research ? (
|
||||||
<BlogWriterLanding
|
<BlogWriterLanding
|
||||||
onStartWriting={() => {
|
onStartWriting={() => {
|
||||||
// This will trigger the copilot to start the research process
|
// Trigger the copilot to start the research process
|
||||||
// The user can then interact with the copilot to begin research
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ padding: 16, borderBottom: '1px solid #eee' }}>
|
<HeaderBar
|
||||||
<h2 style={{ margin: 0 }}>AI Blog Writer</h2>
|
phases={phases}
|
||||||
</div>
|
currentPhase={currentPhase}
|
||||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
onPhaseClick={handlePhaseClick}
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
/>
|
||||||
{research && outline.length === 0 && <ResearchResults research={research} />}
|
<PhaseContent
|
||||||
{outline.length > 0 && (
|
currentPhase={currentPhase}
|
||||||
<div>
|
research={research}
|
||||||
{outlineConfirmed ? (
|
outline={outline}
|
||||||
/* WYSIWYG Editor - Show when outline is confirmed */
|
outlineConfirmed={outlineConfirmed}
|
||||||
<BlogEditor
|
titleOptions={titleOptions}
|
||||||
outline={outline}
|
selectedTitle={selectedTitle}
|
||||||
research={research}
|
researchTitles={researchTitles}
|
||||||
initialTitle={selectedTitle || (typeof window !== 'undefined' ? localStorage.getItem('blog_selected_title') : '') || 'Your Amazing Blog Title'}
|
aiGeneratedTitles={aiGeneratedTitles}
|
||||||
titleOptions={titleOptions}
|
sourceMappingStats={sourceMappingStats}
|
||||||
researchTitles={researchTitles}
|
groundingInsights={groundingInsights}
|
||||||
aiGeneratedTitles={aiGeneratedTitles}
|
optimizationResults={optimizationResults}
|
||||||
sections={sections}
|
researchCoverage={researchCoverage}
|
||||||
onContentUpdate={handleContentUpdate}
|
setOutline={setOutline}
|
||||||
onSave={handleContentSave}
|
sections={sections}
|
||||||
continuityRefresh={continuityRefresh}
|
handleContentUpdate={handleContentUpdate}
|
||||||
flowAnalysisResults={flowAnalysisResults}
|
handleContentSave={handleContentSave}
|
||||||
/>
|
continuityRefresh={continuityRefresh}
|
||||||
) : (
|
flowAnalysisResults={flowAnalysisResults}
|
||||||
/* Outline Editor - Show when outline is not confirmed */
|
outlineGenRef={outlineGenRef}
|
||||||
<>
|
blogWriterApi={blogWriterApi}
|
||||||
{/* Enhanced Title Selection */}
|
contentConfirmed={contentConfirmed}
|
||||||
<EnhancedTitleSelector
|
seoAnalysis={seoAnalysis}
|
||||||
titleOptions={titleOptions}
|
seoMetadata={seoMetadata}
|
||||||
selectedTitle={selectedTitle}
|
onTitleSelect={handleTitleSelect}
|
||||||
sections={outline}
|
onCustomTitle={handleCustomTitle}
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CopilotSidebar
|
<WriterCopilotSidebar
|
||||||
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}
|
suggestions={suggestions}
|
||||||
makeSystemMessage={(context: string, additional?: string) => {
|
research={research}
|
||||||
// Get current state information
|
outline={outline}
|
||||||
const hasResearch = research !== null;
|
outlineConfirmed={outlineConfirmed}
|
||||||
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');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Outline Progress Modal */}
|
<TaskProgressModals
|
||||||
{/* Outline modal */}
|
showOutlineModal={showOutlineModal}
|
||||||
<OutlineProgressModal
|
outlinePolling={outlinePolling}
|
||||||
isVisible={showOutlineModal}
|
showModal={showModal}
|
||||||
status={outlinePolling.currentStatus}
|
rewritePolling={rewritePolling}
|
||||||
progressMessages={outlinePolling.progressMessages.map(m => m.message)}
|
mediumPolling={mediumPolling}
|
||||||
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'}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* SEO Analysis Modal */}
|
{/* SEO Analysis Modal */}
|
||||||
<SEOAnalysisModal
|
<SEOAnalysisModal
|
||||||
isOpen={isSEOAnalysisModalOpen}
|
isOpen={isSEOAnalysisModalOpen}
|
||||||
onClose={() => setIsSEOAnalysisModalOpen(false)}
|
onClose={handleSEOModalClose}
|
||||||
blogContent={buildFullMarkdown()}
|
blogContent={buildFullMarkdown()}
|
||||||
blogTitle={selectedTitle}
|
blogTitle={selectedTitle}
|
||||||
researchData={research}
|
researchData={research}
|
||||||
onApplyRecommendations={(recommendations) => {
|
onApplyRecommendations={handleApplySeoRecommendations}
|
||||||
console.log('Applying SEO recommendations:', recommendations);
|
onAnalysisComplete={handleSEOAnalysisComplete}
|
||||||
// TODO: Implement recommendation application logic
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* SEO Metadata Modal */}
|
{/* SEO Metadata Modal */}
|
||||||
@@ -639,10 +712,14 @@ Available tools:
|
|||||||
blogContent={buildFullMarkdown()}
|
blogContent={buildFullMarkdown()}
|
||||||
blogTitle={selectedTitle}
|
blogTitle={selectedTitle}
|
||||||
researchData={research}
|
researchData={research}
|
||||||
|
outline={outline}
|
||||||
|
seoAnalysis={seoAnalysis}
|
||||||
onMetadataGenerated={(metadata) => {
|
onMetadataGenerated={(metadata) => {
|
||||||
console.log('SEO metadata generated:', metadata);
|
console.log('SEO metadata generated:', metadata);
|
||||||
setSeoMetadata(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>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
|
import { useCopilotTrigger } from '../../hooks/useCopilotTrigger';
|
||||||
|
import BlogWriterPhasesSection from './BlogWriterPhasesSection';
|
||||||
|
|
||||||
interface BlogWriterLandingProps {
|
interface BlogWriterLandingProps {
|
||||||
onStartWriting: () => void;
|
onStartWriting: () => void;
|
||||||
@@ -198,7 +199,7 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SuperPowers Modal */}
|
{/* SuperPowers Modal with 6 Phases */}
|
||||||
{showSuperPowers && (
|
{showSuperPowers && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
@@ -206,20 +207,18 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
backgroundColor: 'rgba(0, 0, 0, 0.95)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
zIndex: 1000
|
zIndex: 1000,
|
||||||
|
overflowY: 'auto'
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
borderRadius: '20px',
|
width: '100%',
|
||||||
padding: '40px',
|
maxWidth: '1400px',
|
||||||
maxWidth: '900px',
|
minHeight: '100%',
|
||||||
width: '90%',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
overflow: 'auto',
|
|
||||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
|
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.3)'
|
||||||
}}>
|
}}>
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
@@ -271,69 +270,82 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SuperPowers Grid */}
|
{/* 6 Phases Section */}
|
||||||
<div style={{
|
<BlogWriterPhasesSection />
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
{/* Quick SuperPowers Grid */}
|
||||||
gap: '24px'
|
<div style={{ padding: '40px', borderTop: '1px solid #f0f0f0' }}>
|
||||||
}}>
|
<h2 style={{
|
||||||
{superPowers.map((power, index) => (
|
margin: '0 0 20px 0',
|
||||||
<div
|
fontSize: '1.5rem',
|
||||||
key={index}
|
textAlign: 'center',
|
||||||
style={{
|
color: '#333'
|
||||||
padding: '24px',
|
}}>
|
||||||
borderRadius: '16px',
|
Quick Feature Overview
|
||||||
border: '1px solid #e0e0e0',
|
</h2>
|
||||||
transition: 'all 0.3s ease'
|
<div style={{
|
||||||
}}
|
display: 'grid',
|
||||||
onMouseEnter={(e) => {
|
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||||
e.currentTarget.style.transform = 'translateY(-4px)';
|
gap: '20px'
|
||||||
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
}}>
|
||||||
e.currentTarget.style.borderColor = '#1976d2';
|
{superPowers.map((power, index) => (
|
||||||
}}
|
<div
|
||||||
onMouseLeave={(e) => {
|
key={index}
|
||||||
e.currentTarget.style.transform = 'translateY(0)';
|
style={{
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
padding: '20px',
|
||||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
borderRadius: '12px',
|
||||||
}}
|
border: '1px solid #e0e0e0',
|
||||||
>
|
transition: 'all 0.3s ease'
|
||||||
<div style={{
|
}}
|
||||||
display: 'flex',
|
onMouseEnter={(e) => {
|
||||||
alignItems: 'center',
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
gap: '16px',
|
e.currentTarget.style.boxShadow = '0 8px 25px rgba(0,0,0,0.1)';
|
||||||
marginBottom: '12px'
|
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={{
|
<div style={{
|
||||||
fontSize: '2rem',
|
|
||||||
width: '60px',
|
|
||||||
height: '60px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: '16px',
|
||||||
background: 'linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%)',
|
marginBottom: '12px'
|
||||||
borderRadius: '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>
|
</div>
|
||||||
<h3 style={{
|
<p style={{
|
||||||
margin: 0,
|
margin: 0,
|
||||||
fontSize: '1.3rem',
|
color: '#666',
|
||||||
color: '#333',
|
lineHeight: '1.6',
|
||||||
fontWeight: '600'
|
fontSize: '0.9rem'
|
||||||
}}>
|
}}>
|
||||||
{power.title}
|
{power.description}
|
||||||
</h3>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p style={{
|
))}
|
||||||
margin: 0,
|
</div>
|
||||||
color: '#666',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
fontSize: '1rem'
|
|
||||||
}}>
|
|
||||||
{power.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* 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 React, { useEffect, useState } from 'react';
|
||||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||||
|
import { debug } from '../../utils/debug';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sectionId: string;
|
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 we have flow analysis results, use them instead of API call
|
||||||
if (flowAnalysisResults && flowAnalysisResults.sections) {
|
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);
|
const sectionAnalysis = flowAnalysisResults.sections.find((s: any) => s.section_id === sectionId);
|
||||||
if (sectionAnalysis) {
|
if (sectionAnalysis) {
|
||||||
console.log('🔍 [ContinuityBadge] Found section analysis:', sectionAnalysis);
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setMetrics({
|
setMetrics({
|
||||||
flow: sectionAnalysis.flow_score, // Already in decimal format (0.0-1.0)
|
flow: sectionAnalysis.flow_score,
|
||||||
consistency: sectionAnalysis.consistency_score,
|
consistency: sectionAnalysis.consistency_score,
|
||||||
progression: sectionAnalysis.progression_score
|
progression: sectionAnalysis.progression_score
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
|
||||||
console.log('🔍 [ContinuityBadge] No matching section found for ID:', sectionId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to API call if no flow analysis results
|
// 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)
|
blogWriterApi.getContinuity(sectionId)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
console.log('🔍 [ContinuityBadge] Received continuity data:', res);
|
|
||||||
if (mounted) setMetrics(res.continuity_metrics || null);
|
if (mounted) setMetrics(res.continuity_metrics || null);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.log('🔍 [ContinuityBadge] Error fetching continuity:', error);
|
debug.error('[ContinuityBadge] fetch error', error);
|
||||||
/* ignore */
|
|
||||||
});
|
});
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [sectionId, refreshToken, flowAnalysisResults]);
|
}, [sectionId, refreshToken, flowAnalysisResults]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
|
||||||
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
|
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
|
||||||
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
|
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
|
||||||
|
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
outline: BlogOutlineSection[];
|
outline: BlogOutlineSection[];
|
||||||
@@ -24,7 +25,10 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||||
|
const [hoveredSection, setHoveredSection] = useState<string | null>(null);
|
||||||
const [showAddSection, setShowAddSection] = useState(false);
|
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({
|
const [newSectionData, setNewSectionData] = useState({
|
||||||
heading: '',
|
heading: '',
|
||||||
subheadings: '',
|
subheadings: '',
|
||||||
@@ -94,6 +98,31 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
|||||||
border: '1px solid #e0e0e0',
|
border: '1px solid #e0e0e0',
|
||||||
overflow: 'hidden'
|
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 */}
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '20px',
|
padding: '20px',
|
||||||
@@ -275,12 +304,15 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
|||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px 20px',
|
padding: '16px 20px',
|
||||||
backgroundColor: expandedSections.has(section.id) ? '#f8f9fa' : 'white',
|
backgroundColor: expandedSections.has(section.id) || hoveredSection === section.id ? '#f8f9fa' : 'white',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
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)}>
|
onClick={() => toggleExpanded(section.id)}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -375,6 +407,24 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
✏️
|
✏️
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -448,7 +498,7 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded Section Content */}
|
{/* Expanded Section Content */}
|
||||||
{expandedSections.has(section.id) && (
|
{(expandedSections.has(section.id) || hoveredSection === section.id) && (
|
||||||
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
|
<div style={{ padding: '0 20px 20px 52px', backgroundColor: '#fafafa' }}>
|
||||||
{/* Subheadings */}
|
{/* Subheadings */}
|
||||||
{section.subheadings && section.subheadings.length > 0 && (
|
{section.subheadings && section.subheadings.length > 0 && (
|
||||||
@@ -533,6 +583,53 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Generated Image Display */}
|
||||||
|
{sectionImages[section.id] && (
|
||||||
|
<div style={{ marginTop: 16, marginBottom: 16 }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>
|
||||||
|
🖼️ Generated Image
|
||||||
|
</h4>
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
maxWidth: '600px',
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${sectionImages[section.id]}`}
|
||||||
|
alt={`Generated image for ${section.heading}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
display: 'block'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setImageModalState({ open: true, sectionId: section.id });
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#1976d2',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate Image for this section
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,269 +12,11 @@ interface KeywordInputFormProps {
|
|||||||
onTaskStart?: (taskId: string) => void;
|
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 }) => {
|
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
|
||||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Keyword input action with Human-in-the-Loop
|
// This component now only provides polling functionality
|
||||||
useCopilotActionTyped({
|
// The keyword input form is handled by ResearchAction component
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
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 { useCopilotAction } from '@copilotkit/react-core';
|
||||||
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
|
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||||
|
|
||||||
@@ -11,18 +11,38 @@ interface OutlineGeneratorProps {
|
|||||||
|
|
||||||
const useCopilotActionTyped = useCopilotAction as any;
|
const useCopilotActionTyped = useCopilotAction as any;
|
||||||
|
|
||||||
export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||||
research,
|
research,
|
||||||
onTaskStart,
|
onTaskStart,
|
||||||
onPollingStart,
|
onPollingStart,
|
||||||
onModalShow
|
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({
|
useCopilotActionTyped({
|
||||||
name: 'generateOutline',
|
name: 'generateOutline',
|
||||||
description: 'Generate outline from research results using AI analysis',
|
description: 'Generate outline from research results using AI analysis',
|
||||||
parameters: [],
|
parameters: [],
|
||||||
handler: async () => {
|
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 {
|
try {
|
||||||
// Show progress modal immediately when user clicks "Create outline"
|
// Show progress modal immediately when user clicks "Create outline"
|
||||||
@@ -64,7 +84,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
render: ({ status }: any) => {
|
render: ({ status }: any) => {
|
||||||
console.log('generateOutline render called with status:', status);
|
|
||||||
if (status === 'inProgress' || status === 'executing') {
|
if (status === 'inProgress' || status === 'executing') {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -105,6 +124,6 @@ export const OutlineGenerator: React.FC<OutlineGeneratorProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return null; // This component only provides the copilot action
|
return null; // This component only provides the copilot action
|
||||||
};
|
});
|
||||||
|
|
||||||
export default OutlineGenerator;
|
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 { useCopilotAction } from '@copilotkit/react-core';
|
||||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||||
import { useResearchPolling } from '../../hooks/usePolling';
|
import { useResearchPolling } from '../../hooks/usePolling';
|
||||||
@@ -15,13 +15,18 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
|||||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||||
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
|
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({
|
const polling = useResearchPolling({
|
||||||
onProgress: (message) => {
|
onProgress: (message) => {
|
||||||
setCurrentMessage(message);
|
setCurrentMessage(message);
|
||||||
|
setForceUpdate(prev => prev + 1); // Force re-render
|
||||||
},
|
},
|
||||||
onComplete: (result) => {
|
onComplete: (result) => {
|
||||||
// Cache the result for future use
|
|
||||||
if (result && result.keywords) {
|
if (result && result.keywords) {
|
||||||
researchCache.cacheResult(
|
researchCache.cacheResult(
|
||||||
result.keywords,
|
result.keywords,
|
||||||
@@ -35,84 +40,170 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
|||||||
setCurrentTaskId(null);
|
setCurrentTaskId(null);
|
||||||
setCurrentMessage('');
|
setCurrentMessage('');
|
||||||
setShowProgressModal(false);
|
setShowProgressModal(false);
|
||||||
|
setForceUpdate(prev => prev + 1);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Research polling error:', error);
|
console.error('Research polling error:', error);
|
||||||
setCurrentTaskId(null);
|
setCurrentTaskId(null);
|
||||||
setCurrentMessage('');
|
setCurrentMessage('');
|
||||||
setShowProgressModal(false);
|
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({
|
useCopilotActionTyped({
|
||||||
name: 'researchTopic',
|
name: 'researchTopic',
|
||||||
description: 'Research topic with keywords and persona context using Google Search grounding',
|
description: 'Research topic with keywords and persona context using Google Search grounding',
|
||||||
parameters: [
|
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: 'industry', type: 'string', description: 'Industry', required: false },
|
||||||
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
|
{ name: 'target_audience', type: 'string', description: 'Target audience', required: false },
|
||||||
{ name: 'blogLength', type: 'string', description: 'Target blog length in words', 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 {
|
try {
|
||||||
// If keywords is a topic description, preserve as single phrase unless comma-separated
|
const trimmed = keywords.trim();
|
||||||
const keywordList = keywords.includes(',')
|
if (!trimmed) {
|
||||||
? keywords.split(',').map(k => k.trim())
|
return "Please provide keywords or a topic for research.";
|
||||||
: [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 keywordList = trimmed.includes(',')
|
||||||
|
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
|
||||||
|
: [trimmed];
|
||||||
const payload: BlogResearchRequest = {
|
const payload: BlogResearchRequest = {
|
||||||
keywords: keywordList,
|
keywords: keywordList,
|
||||||
industry: industryValue,
|
industry,
|
||||||
target_audience: audienceValue,
|
target_audience,
|
||||||
word_count_target: blogLength ? parseInt(blogLength) : 1000
|
word_count_target: parseInt(blogLength)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start async research
|
|
||||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||||
setCurrentTaskId(task_id);
|
setCurrentTaskId(task_id);
|
||||||
setShowProgressModal(true);
|
setShowProgressModal(true);
|
||||||
polling.startPolling(task_id);
|
polling.startPolling(task_id);
|
||||||
|
return "Starting research with your provided keywords.";
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: `🔍 Research started for "${keywords}"! Task ID: ${task_id}. Progress will be shown below.`,
|
|
||||||
task_id: task_id
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Research failed: ${error}`);
|
console.error('Failed to start research:', error);
|
||||||
return {
|
return "Failed to start research. Please try again.";
|
||||||
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.`
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
render: () => null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResearchProgressModal
|
<>
|
||||||
open={showProgressModal}
|
{showProgressModal && (
|
||||||
title="Research in progress"
|
<ResearchProgressModal
|
||||||
status={polling.currentStatus}
|
open={showProgressModal}
|
||||||
messages={polling.progressMessages}
|
title={"Research in progress"}
|
||||||
error={polling.error}
|
status={polling.currentStatus}
|
||||||
onClose={() => setShowProgressModal(false)}
|
messages={polling.progressMessages}
|
||||||
/>
|
error={polling.error}
|
||||||
|
onClose={() => setShowProgressModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useResearchPolling } from '../../hooks/usePolling';
|
|||||||
import ResearchProgressModal from './ResearchProgressModal';
|
import ResearchProgressModal from './ResearchProgressModal';
|
||||||
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
import { BlogResearchResponse } from '../../services/blogWriterApi';
|
||||||
import { researchCache } from '../../services/researchCache';
|
import { researchCache } from '../../services/researchCache';
|
||||||
|
import { debug } from '../../utils/debug';
|
||||||
|
|
||||||
interface ResearchPollingHandlerProps {
|
interface ResearchPollingHandlerProps {
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
@@ -19,11 +20,11 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
|||||||
|
|
||||||
const polling = useResearchPolling({
|
const polling = useResearchPolling({
|
||||||
onProgress: (message) => {
|
onProgress: (message) => {
|
||||||
console.log('ResearchPollingHandler - Progress message received:', message);
|
debug.log('[ResearchPollingHandler] progress', { message });
|
||||||
setCurrentMessage(message);
|
setCurrentMessage(message);
|
||||||
},
|
},
|
||||||
onComplete: (result) => {
|
onComplete: (result) => {
|
||||||
console.log('ResearchPollingHandler - Research completed:', result);
|
debug.log('[ResearchPollingHandler] complete');
|
||||||
|
|
||||||
// Cache the result for future use
|
// Cache the result for future use
|
||||||
if (result && result.keywords) {
|
if (result && result.keywords) {
|
||||||
@@ -39,7 +40,7 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
|||||||
setCurrentMessage('');
|
setCurrentMessage('');
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error('Research polling error:', error);
|
debug.error('[ResearchPollingHandler] error', error);
|
||||||
onError?.(error);
|
onError?.(error);
|
||||||
setCurrentMessage('');
|
setCurrentMessage('');
|
||||||
}
|
}
|
||||||
@@ -61,14 +62,14 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
|||||||
};
|
};
|
||||||
}, [polling]);
|
}, [polling]);
|
||||||
|
|
||||||
console.log('ResearchPollingHandler render:', {
|
// Only log on meaningful changes
|
||||||
taskId,
|
useEffect(() => {
|
||||||
isPolling: polling.isPolling,
|
debug.log('[ResearchPollingHandler] state', {
|
||||||
status: polling.currentStatus,
|
isPolling: polling.isPolling,
|
||||||
progressMessages: polling.progressMessages?.length,
|
status: polling.currentStatus,
|
||||||
currentMessage,
|
progressCount: polling.progressMessages?.length || 0
|
||||||
error: polling.error
|
});
|
||||||
});
|
}, [polling.isPolling, polling.currentStatus, polling.progressMessages?.length]);
|
||||||
|
|
||||||
// Render the unified research progress modal when a task is present
|
// Render the unified research progress modal when a task is present
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Keyword Analysis Component
|
* Keyword Analysis Component
|
||||||
*
|
*
|
||||||
* Displays comprehensive keyword analysis including keyword types, densities,
|
* Displays comprehensive keyword analysis including keyword types, densities,
|
||||||
* missing keywords, over-optimization, and distribution analysis.
|
* missing keywords, over-optimization, and distribution analysis.
|
||||||
*/
|
*/
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
GpsFixed,
|
GpsFixed,
|
||||||
Search,
|
Search,
|
||||||
Warning
|
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 }) => {
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<GpsFixed sx={{ color: 'primary.main' }} />
|
<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
|
Keyword Analysis
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{/* Keyword Types Overview */}
|
{/* Keyword Types Overview */}
|
||||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
<Paper sx={baseCardSx}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
|
||||||
Keyword Types Found
|
Keyword Types Found
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={4}>
|
<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)' }}>
|
<Box sx={subCard('rgba(34,197,94,0.5)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#16a34a', mb: 1 }}>
|
||||||
Primary Keywords
|
Primary Keywords
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||||
{detailedAnalysis?.keyword_analysis?.primary_keywords?.length || 0} found
|
{keywordData?.primary_keywords?.length || 0} found
|
||||||
</Typography>
|
</Typography>
|
||||||
{detailedAnalysis?.keyword_analysis?.primary_keywords?.slice(0, 3).map((keyword: string) => (
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
<Chip key={keyword} label={keyword} size="small" sx={{ mr: 0.5, mb: 0.5 }} />
|
{keywordData?.primary_keywords?.slice(0, 3).map((keyword) => (
|
||||||
))}
|
<Chip key={keyword} label={keyword} size="small" sx={{ fontWeight: 600 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={4}>
|
<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)' }}>
|
<Box sx={subCard('rgba(59,130,246,0.5)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#2563eb', mb: 1 }}>
|
||||||
Long-tail Keywords
|
Long-tail Keywords
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||||
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.length || 0} found
|
{keywordData?.long_tail_keywords?.length || 0} found
|
||||||
</Typography>
|
</Typography>
|
||||||
{detailedAnalysis?.keyword_analysis?.long_tail_keywords?.slice(0, 2).map((keyword: string) => (
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
<Chip key={keyword} label={keyword} size="small" variant="outlined" sx={{ mr: 0.5, mb: 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>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={4}>
|
<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)' }}>
|
<Box sx={subCard('rgba(168,85,247,0.5)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#9333ea', mb: 1 }}>
|
||||||
Semantic Keywords
|
Semantic Keywords
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||||
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.length || 0} found
|
{keywordData?.semantic_keywords?.length || 0} found
|
||||||
</Typography>
|
</Typography>
|
||||||
{detailedAnalysis?.keyword_analysis?.semantic_keywords?.slice(0, 2).map((keyword: string) => (
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
<Chip key={keyword} label={keyword} size="small" variant="outlined" color="secondary" sx={{ mr: 0.5, mb: 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>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Keyword Densities */}
|
{/* 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 }}>
|
<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
|
Keyword Densities
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<Box sx={{ p: 1 }}>
|
<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
|
Keyword Density Analysis
|
||||||
</Typography>
|
</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.
|
Shows how frequently each keyword appears in your content as a percentage of total words.
|
||||||
</Typography>
|
</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
|
<strong>Optimal Range:</strong> 1-3% for primary keywords
|
||||||
</Typography>
|
</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
|
<strong>Too Low (<1%):</strong> Keyword may not be prominent enough
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||||
<strong>Too High (>3%):</strong> Risk of keyword stuffing
|
<strong>Too High (>3%):</strong> Risk of keyword stuffing
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -123,108 +177,96 @@ export const KeywordAnalysis: React.FC<KeywordAnalysisProps> = ({ detailedAnalys
|
|||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||||
<Search />
|
<Search fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.2 }}>
|
||||||
{detailedAnalysis?.keyword_analysis?.keyword_density && Object.keys(detailedAnalysis.keyword_analysis.keyword_density).length > 0 ? (
|
{keywordData?.keyword_density && Object.keys(keywordData.keyword_density).length > 0 ? (
|
||||||
Object.entries(detailedAnalysis.keyword_analysis.keyword_density).map(([keyword, density]) => (
|
Object.entries(keywordData.keyword_density).map(([keyword, density]) => renderDensityRow(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>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<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.
|
No keyword density data available. Make sure your research data includes target keywords.
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Missing Keywords */}
|
{/* Missing Keywords */}
|
||||||
{detailedAnalysis?.keyword_analysis?.missing_keywords && detailedAnalysis.keyword_analysis.missing_keywords.length > 0 && (
|
{keywordData?.missing_keywords && keywordData.missing_keywords.length > 0 && (
|
||||||
<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 }}>
|
<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
|
Missing Keywords
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip
|
<Tooltip title="Keywords from your research that are not found in the content. Consider adding these to improve SEO." arrow>
|
||||||
title="Keywords from your research that are not found in the content. Consider adding these to improve SEO."
|
<IconButton size="small" sx={{ color: '#dc2626' }}>
|
||||||
arrow
|
<Warning fontSize="small" />
|
||||||
>
|
|
||||||
<IconButton size="small" sx={{ color: 'error.main' }}>
|
|
||||||
<Warning />
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{detailedAnalysis.keyword_analysis.missing_keywords.map((keyword: string) => (
|
{keywordData.missing_keywords.map((keyword) => (
|
||||||
<Chip key={keyword} label={keyword} color="error" variant="outlined" />
|
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fecaca', color: '#b91c1c', fontWeight: 600 }} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Over-Optimized Keywords */}
|
{/* Over-Optimized Keywords */}
|
||||||
{detailedAnalysis?.keyword_analysis?.over_optimization && detailedAnalysis.keyword_analysis.over_optimization.length > 0 && (
|
{keywordData?.over_optimization && keywordData.over_optimization.length > 0 && (
|
||||||
<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 }}>
|
<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
|
Over-Optimized Keywords
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip
|
<Tooltip title="Keywords that appear too frequently (over 3% density). Consider reducing their usage." arrow>
|
||||||
title="Keywords that appear too frequently (over 3% density). Consider reducing their usage to avoid keyword stuffing penalties."
|
<IconButton size="small" sx={{ color: '#d97706' }}>
|
||||||
arrow
|
<Warning fontSize="small" />
|
||||||
>
|
|
||||||
<IconButton size="small" sx={{ color: 'warning.main' }}>
|
|
||||||
<Warning />
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
||||||
{detailedAnalysis.keyword_analysis.over_optimization.map((keyword: string) => (
|
{keywordData.over_optimization.map((keyword) => (
|
||||||
<Chip key={keyword} label={keyword} color="warning" variant="outlined" />
|
<Chip key={keyword} label={keyword} variant="outlined" size="small" sx={{ borderColor: '#fcd34d', color: '#b45309', fontWeight: 600 }} />
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Keyword Distribution Analysis */}
|
{/* Keyword Distribution Analysis */}
|
||||||
{detailedAnalysis?.keyword_analysis?.keyword_distribution && Object.keys(detailedAnalysis.keyword_analysis.keyword_distribution).length > 0 && (
|
{keywordData?.keyword_distribution && Object.keys(keywordData.keyword_distribution).length > 0 && (
|
||||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
<Paper sx={baseCardSx}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#0f172a', mb: 2 }}>
|
||||||
Keyword Distribution Analysis
|
Keyword Distribution Analysis
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{Object.entries(detailedAnalysis.keyword_analysis.keyword_distribution).map(([keyword, data]: [string, any]) => (
|
{Object.entries(keywordData.keyword_distribution).map(([keyword, data]: [string, any]) => (
|
||||||
<Box key={keyword} sx={{ p: 2, background: 'rgba(0,0,0,0.02)', borderRadius: 2 }}>
|
<Box
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
key={keyword}
|
||||||
"{keyword}"
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
backgroundColor: '#f8fafc'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0f172a', mb: 1 }}>
|
||||||
|
“{keyword}”
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={6}>
|
<Grid item xs={6}>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||||
Density: {data.density?.toFixed(1)}%
|
Density: {data.density?.toFixed(1)}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6}>
|
<Grid item xs={6}>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||||
In Headings: {data.in_headings ? 'Yes' : 'No'}
|
In Headings: {data.in_headings ? 'Yes' : 'No'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<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'}
|
First Occurrence: Character {data.first_occurrence || 'Not found'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -75,9 +75,22 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
return `${current}/${max}`;
|
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 (
|
return (
|
||||||
<Box>
|
<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' }} />
|
<SearchIcon sx={{ color: 'primary.main' }} />
|
||||||
Core SEO Metadata
|
Core SEO Metadata
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -85,10 +98,10 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* SEO Title */}
|
{/* SEO Title */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<SearchIcon sx={{ fontSize: 20 }} />
|
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||||
SEO Title
|
SEO Title
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -107,6 +120,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
value={metadata.seo_title || ''}
|
value={metadata.seo_title || ''}
|
||||||
onChange={handleTextFieldChange('seo_title')}
|
onChange={handleTextFieldChange('seo_title')}
|
||||||
placeholder="Enter SEO-optimized title (50-60 characters)"
|
placeholder="Enter SEO-optimized title (50-60 characters)"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
@@ -120,18 +134,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Alert severity="info" sx={{ mt: 1 }}>
|
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||||
Include your primary keyword and make it compelling for clicks
|
Include your primary keyword and keep between 50–60 characters
|
||||||
</Alert>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Meta Description */}
|
{/* Meta Description */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<SearchIcon sx={{ fontSize: 20 }} />
|
<SearchIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||||
Meta Description
|
Meta Description
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -150,6 +164,7 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
value={metadata.meta_description || ''}
|
value={metadata.meta_description || ''}
|
||||||
onChange={handleTextFieldChange('meta_description')}
|
onChange={handleTextFieldChange('meta_description')}
|
||||||
placeholder="Enter compelling meta description (150-160 characters)"
|
placeholder="Enter compelling meta description (150-160 characters)"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
@@ -163,18 +178,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Alert severity="info" sx={{ mt: 1 }}>
|
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||||
Include a call-to-action and your primary keyword
|
Aim for 150–160 characters with a clear value proposition
|
||||||
</Alert>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* URL Slug */}
|
{/* URL Slug */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<LinkIcon sx={{ fontSize: 20 }} />
|
<LinkIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||||
URL Slug
|
URL Slug
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -192,16 +207,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
onChange={handleTextFieldChange('url_slug')}
|
onChange={handleTextFieldChange('url_slug')}
|
||||||
placeholder="seo-friendly-url-slug"
|
placeholder="seo-friendly-url-slug"
|
||||||
helperText="Use lowercase letters, numbers, and hyphens only"
|
helperText="Use lowercase letters, numbers, and hyphens only"
|
||||||
|
sx={textInputSx}
|
||||||
|
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Focus Keyword */}
|
{/* Focus Keyword */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<TrendingUpIcon sx={{ fontSize: 20 }} />
|
<TrendingUpIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||||
Focus Keyword
|
Focus Keyword
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -219,16 +236,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
onChange={handleTextFieldChange('focus_keyword')}
|
onChange={handleTextFieldChange('focus_keyword')}
|
||||||
placeholder="primary-keyword"
|
placeholder="primary-keyword"
|
||||||
helperText="Your main SEO keyword for this post"
|
helperText="Your main SEO keyword for this post"
|
||||||
|
sx={textInputSx}
|
||||||
|
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Blog Tags */}
|
{/* Blog Tags */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<TagIcon sx={{ fontSize: 20 }} />
|
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||||
Blog Tags
|
Blog Tags
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -241,12 +260,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Tags</InputLabel>
|
<InputLabel sx={{ color: '#5f6368' }}>Tags</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
multiple
|
multiple
|
||||||
value={metadata.blog_tags || []}
|
value={metadata.blog_tags || []}
|
||||||
onChange={handleTagsChange('blog_tags')}
|
onChange={handleTagsChange('blog_tags')}
|
||||||
input={<OutlinedInput label="Tags" />}
|
input={<OutlinedInput label="Tags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||||
renderValue={(selected) => (
|
renderValue={(selected) => (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{selected.map((value: string) => (
|
{selected.map((value: string) => (
|
||||||
@@ -262,18 +281,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Alert severity="info" sx={{ mt: 1 }}>
|
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||||
Add relevant tags for better categorization and discoverability
|
Add 3–6 relevant tags for better categorization and discoverability
|
||||||
</Alert>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Blog Categories */}
|
{/* Blog Categories */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<CategoryIcon sx={{ fontSize: 20 }} />
|
<CategoryIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||||
Blog Categories
|
Blog Categories
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -286,12 +305,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Categories</InputLabel>
|
<InputLabel sx={{ color: '#5f6368' }}>Categories</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
multiple
|
multiple
|
||||||
value={metadata.blog_categories || []}
|
value={metadata.blog_categories || []}
|
||||||
onChange={handleTagsChange('blog_categories')}
|
onChange={handleTagsChange('blog_categories')}
|
||||||
input={<OutlinedInput label="Categories" />}
|
input={<OutlinedInput label="Categories" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||||
renderValue={(selected) => (
|
renderValue={(selected) => (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{selected.map((value: string) => (
|
{selected.map((value: string) => (
|
||||||
@@ -307,18 +326,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Alert severity="info" sx={{ mt: 1 }}>
|
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||||
Select 2-3 primary categories for your content
|
Select 1–3 primary categories for your content
|
||||||
</Alert>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Social Hashtags */}
|
{/* Social Hashtags */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<TagIcon sx={{ fontSize: 20 }} />
|
<TagIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||||
Social Hashtags
|
Social Hashtags
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -331,12 +350,12 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<InputLabel>Hashtags</InputLabel>
|
<InputLabel sx={{ color: '#5f6368' }}>Hashtags</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
multiple
|
multiple
|
||||||
value={metadata.social_hashtags || []}
|
value={metadata.social_hashtags || []}
|
||||||
onChange={handleTagsChange('social_hashtags')}
|
onChange={handleTagsChange('social_hashtags')}
|
||||||
input={<OutlinedInput label="Hashtags" />}
|
input={<OutlinedInput label="Hashtags" sx={{ color: '#202124', '& .MuiOutlinedInput-notchedOutline': { borderColor: '#dadce0' } }} />}
|
||||||
renderValue={(selected) => (
|
renderValue={(selected) => (
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||||
{selected.map((value: string) => (
|
{selected.map((value: string) => (
|
||||||
@@ -352,18 +371,18 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<Alert severity="info" sx={{ mt: 1 }}>
|
<Typography variant="caption" sx={{ mt: 1, color: '#5f6368', display: 'block' }}>
|
||||||
Include # symbol for social media platforms
|
Include # symbol (e.g., #multimodalAI). 3–5 hashtags recommended.
|
||||||
</Alert>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Reading Time */}
|
{/* Reading Time */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<ScheduleIcon sx={{ fontSize: 20 }} />
|
<ScheduleIcon sx={{ fontSize: 20, color: '#5f6368' }} />
|
||||||
Reading Time
|
Reading Time
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -385,6 +404,8 @@ export const CoreMetadataTab: React.FC<CoreMetadataTabProps> = ({
|
|||||||
endAdornment: <InputAdornment position="end">minutes</InputAdornment>
|
endAdornment: <InputAdornment position="end">minutes</InputAdornment>
|
||||||
}}
|
}}
|
||||||
helperText="Estimated reading time for your content"
|
helperText="Estimated reading time for your content"
|
||||||
|
sx={textInputSx}
|
||||||
|
FormHelperTextProps={{ sx: { color: '#5f6368' } }}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -12,28 +12,35 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
Paper,
|
Paper,
|
||||||
Grid,
|
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Chip,
|
Chip,
|
||||||
Alert
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Tooltip,
|
||||||
|
IconButton
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Search as SearchIcon,
|
Search as SearchIcon,
|
||||||
Code as CodeIcon,
|
Code as CodeIcon,
|
||||||
Facebook as FacebookIcon,
|
Facebook as FacebookIcon,
|
||||||
Twitter as TwitterIcon,
|
Twitter as TwitterIcon,
|
||||||
Google as GoogleIcon
|
Google as GoogleIcon,
|
||||||
|
Info as InfoIcon
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
interface PreviewCardProps {
|
interface PreviewCardProps {
|
||||||
metadata: any;
|
metadata: any;
|
||||||
blogTitle: string;
|
blogTitle: string;
|
||||||
|
previewTabValue: string;
|
||||||
|
onPreviewTabChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PreviewCard: React.FC<PreviewCardProps> = ({
|
export const PreviewCard: React.FC<PreviewCardProps> = ({
|
||||||
metadata,
|
metadata,
|
||||||
blogTitle
|
blogTitle,
|
||||||
|
previewTabValue,
|
||||||
|
onPreviewTabChange
|
||||||
}) => {
|
}) => {
|
||||||
const getCurrentDate = () => {
|
const getCurrentDate = () => {
|
||||||
return new Date().toLocaleDateString('en-US', {
|
return new Date().toLocaleDateString('en-US', {
|
||||||
@@ -45,320 +52,491 @@ export const PreviewCard: React.FC<PreviewCardProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<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' }} />
|
<SearchIcon sx={{ color: 'primary.main' }} />
|
||||||
Live Preview
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
</Typography>
|
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}>
|
{/* Platform Sub-Tabs */}
|
||||||
{/* Google Search Results Preview */}
|
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||||
<Grid item xs={12}>
|
<Tabs
|
||||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
value={previewTabValue}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
onChange={(e, newValue) => onPreviewTabChange(newValue)}
|
||||||
<GoogleIcon sx={{ color: '#4285F4' }} />
|
variant="scrollable"
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
scrollButtons="auto"
|
||||||
Google Search Results
|
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>
|
</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' }}>
|
{/* Facebook Preview */}
|
||||||
<CardContent sx={{ p: 2 }}>
|
{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 */}
|
{/* URL */}
|
||||||
<Typography variant="caption" sx={{ color: '#1a0dab', mb: 1, display: 'block' }}>
|
<Typography
|
||||||
{metadata.canonical_url || 'https://yourwebsite.com/blog-post'}
|
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>
|
</Typography>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Typography
|
<Typography
|
||||||
variant="h6"
|
variant="subtitle1"
|
||||||
sx={{
|
sx={{
|
||||||
color: '#1a0dab',
|
fontWeight: 600,
|
||||||
fontWeight: 400,
|
mb: 1,
|
||||||
fontSize: '1.1rem',
|
lineHeight: 1.33,
|
||||||
lineHeight: 1.3,
|
fontSize: '17px',
|
||||||
mb: 1,
|
color: '#050505',
|
||||||
cursor: 'pointer',
|
fontFamily: 'Helvetica, Arial, sans-serif'
|
||||||
'&:hover': { textDecoration: 'underline' }
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{metadata.seo_title || blogTitle}
|
{metadata.open_graph?.title || metadata.seo_title || blogTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<Typography variant="body2" sx={{ color: '#4d5156', lineHeight: 1.4, mb: 1 }}>
|
<Typography
|
||||||
{metadata.meta_description || 'Your meta description will appear here in Google search results...'}
|
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>
|
</Typography>
|
||||||
|
|
||||||
{/* Additional Info */}
|
{/* Title */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
<Typography
|
||||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
variant="subtitle1"
|
||||||
{getCurrentDate()}
|
sx={{
|
||||||
</Typography>
|
fontWeight: 600,
|
||||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
mb: 1,
|
||||||
•
|
lineHeight: 1.33,
|
||||||
</Typography>
|
fontSize: '15px',
|
||||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
color: '#0f1419',
|
||||||
{metadata.reading_time || 5} min read
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||||
</Typography>
|
}}
|
||||||
{metadata.blog_tags && metadata.blog_tags.length > 0 && (
|
>
|
||||||
<>
|
{metadata.twitter_card?.title || metadata.seo_title || blogTitle}
|
||||||
<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...'}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
{/* Description */}
|
||||||
{metadata.json_ld_schema?.author?.name && (
|
<Typography
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
variant="body2"
|
||||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
sx={{
|
||||||
By {metadata.json_ld_schema.author.name}
|
color: '#536471',
|
||||||
</Typography>
|
lineHeight: 1.33,
|
||||||
</Box>
|
fontSize: '15px',
|
||||||
)}
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
|
||||||
|
}}
|
||||||
{metadata.json_ld_schema?.datePublished && (
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
{metadata.twitter_card?.description || metadata.meta_description || 'Your description will appear here...'}
|
||||||
<Typography variant="caption" sx={{ color: '#4d5156' }}>
|
</Typography>
|
||||||
{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>
|
|
||||||
|
|
||||||
{metadata.json_ld_schema?.keywords && metadata.json_ld_schema.keywords.length > 0 && (
|
{/* Twitter handle */}
|
||||||
<Box sx={{ mt: 2 }}>
|
{metadata.twitter_card?.site && (
|
||||||
<Typography variant="caption" sx={{ color: '#4d5156', display: 'block', mb: 1 }}>
|
<Typography
|
||||||
Keywords:
|
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>
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
{metadata.json_ld_schema?.datePublished && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Alert severity="success" sx={{ mt: 2 }}>
|
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||||
Rich snippets help search engines understand your content and may display additional information in search results
|
{new Date(metadata.json_ld_schema.datePublished).toLocaleDateString()}
|
||||||
</Alert>
|
</Typography>
|
||||||
</Paper>
|
</Box>
|
||||||
</Grid>
|
)}
|
||||||
|
|
||||||
{/* Metadata Summary */}
|
{metadata.reading_time && (
|
||||||
<Grid item xs={12}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
{metadata.reading_time} min read
|
||||||
<SearchIcon />
|
</Typography>
|
||||||
Metadata Summary
|
</Box>
|
||||||
</Typography>
|
)}
|
||||||
|
|
||||||
<Grid container spacing={2}>
|
{metadata.json_ld_schema?.wordCount && (
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(76, 175, 80, 0.1)', borderRadius: 2 }}>
|
<Typography variant="caption" sx={{ color: '#70757a', fontSize: '13px' }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'success.main' }}>
|
{metadata.json_ld_schema.wordCount} words
|
||||||
{metadata.optimization_score || 0}%
|
</Typography>
|
||||||
</Typography>
|
</Box>
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
)}
|
||||||
Optimization Score
|
</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>
|
</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>
|
</Box>
|
||||||
</Grid>
|
)}
|
||||||
|
</CardContent>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
</Card>
|
||||||
<Box sx={{ textAlign: 'center', p: 2, bgcolor: 'rgba(33, 150, 243, 0.1)', borderRadius: 2 }}>
|
</Paper>
|
||||||
<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>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,12 +71,25 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
return `${current}/${max}`;
|
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 openGraph = metadata.open_graph || {};
|
||||||
const twitterCard = metadata.twitter_card || {};
|
const twitterCard = metadata.twitter_card || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<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' }} />
|
<ShareIcon sx={{ color: 'primary.main' }} />
|
||||||
Social Media Metadata
|
Social Media Metadata
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -84,11 +97,11 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Open Graph Section */}
|
{/* Open Graph Section */}
|
||||||
<Grid item xs={12}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||||
<LinkedInIcon sx={{ color: '#0077B5' }} />
|
<LinkedInIcon sx={{ color: '#0077B5' }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||||
Open Graph Tags
|
Open Graph Tags
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip label="Facebook & LinkedIn" size="small" color="primary" />
|
<Chip label="Facebook & LinkedIn" size="small" color="primary" />
|
||||||
@@ -97,7 +110,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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
|
OG Title
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -114,6 +127,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
value={openGraph.title || ''}
|
value={openGraph.title || ''}
|
||||||
onChange={handleNestedFieldChange('open_graph', 'title')}
|
onChange={handleNestedFieldChange('open_graph', 'title')}
|
||||||
placeholder="Open Graph title (60 characters max)"
|
placeholder="Open Graph title (60 characters max)"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
@@ -131,7 +145,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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
|
OG Description
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -150,6 +164,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
value={openGraph.description || ''}
|
value={openGraph.description || ''}
|
||||||
onChange={handleNestedFieldChange('open_graph', 'description')}
|
onChange={handleNestedFieldChange('open_graph', 'description')}
|
||||||
placeholder="Open Graph description (160 characters max)"
|
placeholder="Open Graph description (160 characters max)"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
@@ -167,7 +182,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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
|
OG Image URL
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -184,6 +199,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
value={openGraph.image || ''}
|
value={openGraph.image || ''}
|
||||||
onChange={handleNestedFieldChange('open_graph', 'image')}
|
onChange={handleNestedFieldChange('open_graph', 'image')}
|
||||||
placeholder="https://example.com/image.jpg"
|
placeholder="https://example.com/image.jpg"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
@@ -196,7 +212,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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
|
OG URL
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -213,6 +229,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
value={openGraph.url || ''}
|
value={openGraph.url || ''}
|
||||||
onChange={handleNestedFieldChange('open_graph', 'url')}
|
onChange={handleNestedFieldChange('open_graph', 'url')}
|
||||||
placeholder="https://example.com/blog-post"
|
placeholder="https://example.com/blog-post"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
@@ -224,18 +241,18 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Alert severity="info" sx={{ mt: 2 }}>
|
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
|
||||||
Open Graph tags are used by Facebook, LinkedIn, and other social platforms to display rich previews
|
Open Graph tags are used by Facebook, LinkedIn, and others to display rich previews.
|
||||||
</Alert>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Twitter Card Section */}
|
{/* Twitter Card Section */}
|
||||||
<Grid item xs={12}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||||
Twitter Card Tags
|
Twitter Card Tags
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip label="Twitter & X" size="small" color="info" />
|
<Chip label="Twitter & X" size="small" color="info" />
|
||||||
@@ -244,7 +261,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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
|
Twitter Title
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -261,6 +278,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
value={twitterCard.title || ''}
|
value={twitterCard.title || ''}
|
||||||
onChange={handleNestedFieldChange('twitter_card', 'title')}
|
onChange={handleNestedFieldChange('twitter_card', 'title')}
|
||||||
placeholder="Twitter card title (70 characters max)"
|
placeholder="Twitter card title (70 characters max)"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
@@ -278,7 +296,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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
|
Twitter Description
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -297,6 +315,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
value={twitterCard.description || ''}
|
value={twitterCard.description || ''}
|
||||||
onChange={handleNestedFieldChange('twitter_card', 'description')}
|
onChange={handleNestedFieldChange('twitter_card', 'description')}
|
||||||
placeholder="Twitter card description (200 characters max)"
|
placeholder="Twitter card description (200 characters max)"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
@@ -314,7 +333,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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
|
Twitter Image URL
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -331,6 +350,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
value={twitterCard.image || ''}
|
value={twitterCard.image || ''}
|
||||||
onChange={handleNestedFieldChange('twitter_card', 'image')}
|
onChange={handleNestedFieldChange('twitter_card', 'image')}
|
||||||
placeholder="https://example.com/twitter-image.jpg"
|
placeholder="https://example.com/twitter-image.jpg"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
@@ -343,7 +363,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
<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
|
Twitter Site Handle
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip title="Copy to clipboard">
|
<Tooltip title="Copy to clipboard">
|
||||||
@@ -360,6 +380,7 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
value={twitterCard.site || ''}
|
value={twitterCard.site || ''}
|
||||||
onChange={handleNestedFieldChange('twitter_card', 'site')}
|
onChange={handleNestedFieldChange('twitter_card', 'site')}
|
||||||
placeholder="@yourwebsite"
|
placeholder="@yourwebsite"
|
||||||
|
sx={textInputSx}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
@@ -371,16 +392,16 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Alert severity="info" sx={{ mt: 2 }}>
|
<Typography variant="caption" sx={{ mt: 2, color: '#5f6368', display: 'block' }}>
|
||||||
Twitter cards provide rich previews when your content is shared on Twitter/X
|
Twitter cards provide rich previews when your content is shared on Twitter/X.
|
||||||
</Alert>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Social Media Preview */}
|
{/* Social Media Preview */}
|
||||||
<Grid item xs={12}>
|
<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: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, display: 'flex', alignItems: 'center', gap: 1, color: '#202124' }}>
|
||||||
<ShareIcon />
|
<ShareIcon />
|
||||||
Social Media Preview
|
Social Media Preview
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -388,22 +409,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{/* Facebook Preview */}
|
{/* Facebook Preview */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<CardContent sx={{ p: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
<FacebookIcon sx={{ color: '#1877F2' }} />
|
<FacebookIcon sx={{ color: '#1877F2' }} />
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||||
Facebook Preview
|
Facebook Preview
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
|
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
|
||||||
{openGraph.title || 'Your Blog Title'}
|
{openGraph.title || 'Your Blog Title'}
|
||||||
</Typography>
|
</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'}
|
{openGraph.url || 'yourwebsite.com'}
|
||||||
</Typography>
|
</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...'}
|
{openGraph.description || 'Your meta description will appear here...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -413,22 +434,22 @@ export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({
|
|||||||
|
|
||||||
{/* Twitter Preview */}
|
{/* Twitter Preview */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<CardContent sx={{ p: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
<TwitterIcon sx={{ color: '#1DA1F2' }} />
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#202124' }}>
|
||||||
Twitter Preview
|
Twitter Preview
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2, bgcolor: '#f5f5f5' }}>
|
<Box sx={{ border: '1px solid #e0e0e0', borderRadius: 1, p: 2.5, bgcolor: '#fafafa' }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: '#202124' }}>
|
||||||
{twitterCard.title || 'Your Blog Title'}
|
{twitterCard.title || 'Your Blog Title'}
|
||||||
</Typography>
|
</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'}
|
{twitterCard.site || '@yourwebsite'}
|
||||||
</Typography>
|
</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...'}
|
{twitterCard.description || 'Your Twitter description will appear here...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -56,6 +56,28 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [showRawJson, setShowRawJson] = useState(false);
|
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>) => {
|
const handleTextFieldChange = (field: string) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
onMetadataEdit(field, event.target.value);
|
onMetadataEdit(field, event.target.value);
|
||||||
};
|
};
|
||||||
@@ -123,7 +145,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Article Information */}
|
{/* Article Information */}
|
||||||
<Grid item xs={12}>
|
<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 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CodeIcon />
|
<CodeIcon />
|
||||||
Article Schema
|
Article Schema
|
||||||
@@ -149,6 +171,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
value={jsonLdSchema.headline || ''}
|
value={jsonLdSchema.headline || ''}
|
||||||
onChange={handleSchemaFieldChange('headline')}
|
onChange={handleSchemaFieldChange('headline')}
|
||||||
placeholder="Article 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>
|
</Grid>
|
||||||
|
|
||||||
@@ -173,6 +208,19 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
value={jsonLdSchema.description || ''}
|
value={jsonLdSchema.description || ''}
|
||||||
onChange={handleSchemaFieldChange('description')}
|
onChange={handleSchemaFieldChange('description')}
|
||||||
placeholder="Article 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>
|
</Grid>
|
||||||
|
|
||||||
@@ -202,6 +250,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -228,6 +277,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: <InputAdornment position="end">words</InputAdornment>
|
endAdornment: <InputAdornment position="end">words</InputAdornment>
|
||||||
}}
|
}}
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -236,7 +286,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
|
|
||||||
{/* Author Information */}
|
{/* Author Information */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<PersonIcon />
|
<PersonIcon />
|
||||||
Author Information
|
Author Information
|
||||||
@@ -262,6 +312,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
value={author.name || ''}
|
value={author.name || ''}
|
||||||
onChange={handleAuthorFieldChange('name')}
|
onChange={handleAuthorFieldChange('name')}
|
||||||
placeholder="Author Name"
|
placeholder="Author Name"
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -284,6 +335,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
value={author['@type'] || ''}
|
value={author['@type'] || ''}
|
||||||
onChange={handleAuthorFieldChange('@type')}
|
onChange={handleAuthorFieldChange('@type')}
|
||||||
placeholder="Person"
|
placeholder="Person"
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -292,7 +344,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
|
|
||||||
{/* Publisher Information */}
|
{/* Publisher Information */}
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<BusinessIcon />
|
<BusinessIcon />
|
||||||
Publisher Information
|
Publisher Information
|
||||||
@@ -318,6 +370,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
value={publisher.name || ''}
|
value={publisher.name || ''}
|
||||||
onChange={handlePublisherFieldChange('name')}
|
onChange={handlePublisherFieldChange('name')}
|
||||||
placeholder="Publisher Name"
|
placeholder="Publisher Name"
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -340,6 +393,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
value={publisher.logo || ''}
|
value={publisher.logo || ''}
|
||||||
onChange={handlePublisherFieldChange('logo')}
|
onChange={handlePublisherFieldChange('logo')}
|
||||||
placeholder="https://example.com/logo.png"
|
placeholder="https://example.com/logo.png"
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -348,7 +402,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
|
|
||||||
{/* Publication Dates */}
|
{/* Publication Dates */}
|
||||||
<Grid item xs={12}>
|
<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 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CalendarIcon />
|
<CalendarIcon />
|
||||||
Publication Dates
|
Publication Dates
|
||||||
@@ -375,6 +429,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
value={jsonLdSchema.datePublished || ''}
|
value={jsonLdSchema.datePublished || ''}
|
||||||
onChange={handleSchemaFieldChange('datePublished')}
|
onChange={handleSchemaFieldChange('datePublished')}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -398,6 +453,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
value={jsonLdSchema.dateModified || ''}
|
value={jsonLdSchema.dateModified || ''}
|
||||||
onChange={handleSchemaFieldChange('dateModified')}
|
onChange={handleSchemaFieldChange('dateModified')}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -406,7 +462,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
|
|
||||||
{/* Keywords */}
|
{/* Keywords */}
|
||||||
<Grid item xs={12}>
|
<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 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CodeIcon />
|
<CodeIcon />
|
||||||
Keywords & Categories
|
Keywords & Categories
|
||||||
@@ -438,6 +494,7 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
}}
|
}}
|
||||||
placeholder="keyword1, keyword2, keyword3"
|
placeholder="keyword1, keyword2, keyword3"
|
||||||
helperText="Separate keywords with commas"
|
helperText="Separate keywords with commas"
|
||||||
|
sx={textInputSx}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -479,7 +536,9 @@ export const StructuredDataTab: React.FC<StructuredDataTabProps> = ({
|
|||||||
readOnly: true,
|
readOnly: true,
|
||||||
sx: {
|
sx: {
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: '0.875rem'
|
fontSize: '0.875rem',
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#e2e8f0'
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
sx={{
|
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
|
* Readability Analysis Component
|
||||||
*
|
*
|
||||||
* Displays comprehensive readability analysis including readability metrics,
|
* Displays comprehensive readability analysis including readability metrics,
|
||||||
* content statistics, sentence/paragraph analysis, and target audience information.
|
* content statistics, sentence/paragraph analysis, and target audience information.
|
||||||
*/
|
*/
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
MenuBook
|
MenuBook
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
|
||||||
@@ -57,109 +57,186 @@ interface ReadabilityAnalysisProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReadabilityAnalysis: React.FC<ReadabilityAnalysisProps> = ({
|
const cardStyles = {
|
||||||
detailedAnalysis,
|
p: 3,
|
||||||
visualizationData
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<MenuBook sx={{ color: 'primary.main' }} />
|
<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
|
Readability Analysis
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid item xs={12} md={6}>
|
<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 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||||
Readability Metrics
|
Readability Metrics
|
||||||
</Typography>
|
</Typography>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||||
Readability Analysis
|
Readability Analysis
|
||||||
</Typography>
|
</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.
|
Measures how easy your content is to read and understand.
|
||||||
</Typography>
|
</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)
|
<strong>Flesch Reading Ease:</strong> 90-100 (Very Easy), 80-89 (Easy), 70-79 (Fairly Easy), 60-69 (Standard)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
<Typography variant="caption" sx={{ display: 'block', mb: 0.75, color: '#64748b' }}>
|
||||||
<strong>Average Sentence Length:</strong> 15-20 words is optimal
|
<strong>Sentence Length:</strong> 15-20 words is optimal
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||||
<strong>Average Syllables per Word:</strong> 1.5-1.7 is ideal
|
<strong>Syllables per Word:</strong> 1.5-1.7 keeps content approachable
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
<IconButton size="small" sx={{ color: 'primary.main' }}>
|
||||||
<MenuBook />
|
<MenuBook fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
{detailedAnalysis?.readability_analysis?.metrics && Object.keys(detailedAnalysis.readability_analysis.metrics).length > 0 ? (
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
|
||||||
Object.entries(detailedAnalysis.readability_analysis.metrics).map(([metric, value]) => {
|
{Object.keys(readabilityMetrics).length > 0 ? (
|
||||||
const getReadabilityTooltip = (metric: string, value: number) => {
|
Object.entries(readabilityMetrics).map(([metric, value]) => {
|
||||||
const tooltips = {
|
const { description, interpretation } = getMetricDetails(metric, value);
|
||||||
flesch_reading_ease: {
|
const label = metric.replace('_', ' ').replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
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);
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
key={metric}
|
key={metric}
|
||||||
title={
|
title={
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||||
{metric.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||||
{tooltip.description}
|
{description}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption">
|
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||||
<strong>Interpretation:</strong> {tooltip.interpretation}
|
<strong>Interpretation:</strong> {interpretation}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
arrow
|
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' }}>
|
<Box sx={metricRowSx}>
|
||||||
<Typography variant="body2" sx={{ textTransform: 'capitalize' }}>
|
<Typography variant="body2" sx={{ textTransform: 'capitalize', color: '#334155' }}>
|
||||||
{metric.replace('_', ' ')}
|
{metric.replace('_', ' ')}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={{ fontWeight: 700, color: '#0f172a' }}>
|
||||||
{value.toFixed(1)}
|
{value.toFixed(1)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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.
|
No readability metrics available. This may indicate an issue with the content analysis.
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
<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}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||||
Content Statistics
|
Content Statistics
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
{renderStatRow('Word Count', detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count || 'N/A')}
|
||||||
<Typography variant="body2">Word Count</Typography>
|
{renderStatRow('Sections', detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections || 'N/A')}
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
{renderStatRow('Paragraphs', detailedAnalysis?.content_structure?.total_paragraphs || visualizationData?.content_stats.paragraphs || 'N/A')}
|
||||||
{detailedAnalysis?.content_quality?.word_count || visualizationData?.content_stats.word_count}
|
{renderStatRow('Sentences', detailedAnalysis?.content_structure?.total_sentences || 'N/A')}
|
||||||
</Typography>
|
{renderStatRow('Unique Words', detailedAnalysis?.content_quality?.unique_words || 'N/A')}
|
||||||
</Box>
|
{renderStatRow(
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
'Vocabulary Diversity',
|
||||||
<Typography variant="body2">Sections</Typography>
|
detailedAnalysis?.content_quality?.vocabulary_diversity !== undefined
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
? `${(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1)}%`
|
||||||
{detailedAnalysis?.content_structure?.total_sections || visualizationData?.content_stats.sections}
|
: 'N/A'
|
||||||
</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>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Additional Readability Metrics */}
|
|
||||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||||
<Grid item xs={12} md={6}>
|
<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}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||||
Sentence & Paragraph Analysis
|
Sentence & Paragraph Analysis
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
{renderStatRow(
|
||||||
<Typography variant="body2">Avg Sentence Length</Typography>
|
'Average Sentence Length',
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
detailedAnalysis?.readability_analysis?.avg_sentence_length !== undefined
|
||||||
{detailedAnalysis?.readability_analysis?.avg_sentence_length?.toFixed(1) || 'N/A'} words
|
? `${detailedAnalysis.readability_analysis.avg_sentence_length.toFixed(1)} words`
|
||||||
</Typography>
|
: 'N/A'
|
||||||
</Box>
|
)}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
{renderStatRow(
|
||||||
<Typography variant="body2">Avg Paragraph Length</Typography>
|
'Average Paragraph Length',
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
detailedAnalysis?.readability_analysis?.avg_paragraph_length !== undefined
|
||||||
{detailedAnalysis?.readability_analysis?.avg_paragraph_length?.toFixed(1) || 'N/A'} words
|
? `${detailedAnalysis.readability_analysis.avg_paragraph_length.toFixed(1)} words`
|
||||||
</Typography>
|
: 'N/A'
|
||||||
</Box>
|
)}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
{renderStatRow(
|
||||||
<Typography variant="body2">Transition Words</Typography>
|
'Transition Words Used',
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
detailedAnalysis?.content_quality?.transition_words_used || 'N/A'
|
||||||
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
|
)}
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={6}>
|
<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}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={sectionTitleSx}>
|
||||||
Target Audience
|
Target Audience
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
{renderStatRow('Reading Level', detailedAnalysis?.readability_analysis?.target_audience || 'N/A')}
|
||||||
<Typography variant="body2">Reading Level</Typography>
|
{renderStatRow('Content Depth Score', detailedAnalysis?.content_quality?.content_depth_score || 'N/A')}
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
{renderStatRow('Flow Score', detailedAnalysis?.content_quality?.flow_score || 'N/A')}
|
||||||
{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>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Recommendations Component
|
* Recommendations Component
|
||||||
*
|
*
|
||||||
* Displays actionable SEO recommendations with priority indicators,
|
* Displays actionable SEO recommendations with priority indicators,
|
||||||
* category tags, and impact descriptions.
|
* category tags, and impact descriptions.
|
||||||
*/
|
*/
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Chip
|
Chip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Cancel,
|
Cancel,
|
||||||
@@ -30,78 +30,107 @@ interface RecommendationsProps {
|
|||||||
recommendations: Recommendation[];
|
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 }) => {
|
export const Recommendations: React.FC<RecommendationsProps> = ({ recommendations }) => {
|
||||||
const getPriorityColor = (priority: string) => {
|
const getPriorityColor = (priority: string) => priorityStyles[priority]?.color || priorityStyles.default.color;
|
||||||
switch (priority) {
|
|
||||||
case 'High': return 'error.main';
|
|
||||||
case 'Medium': return 'warning.main';
|
|
||||||
case 'Low': return 'success.main';
|
|
||||||
default: return 'text.secondary';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPriorityIcon = (priority: string) => {
|
const getPriorityIcon = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'High': return <Cancel sx={{ fontSize: 16 }} />;
|
case 'High':
|
||||||
case 'Medium': return <Warning sx={{ fontSize: 16 }} />;
|
return <Cancel sx={{ fontSize: 18 }} />;
|
||||||
case 'Low': return <CheckCircle sx={{ fontSize: 16 }} />;
|
case 'Medium':
|
||||||
default: return <Warning sx={{ fontSize: 16 }} />;
|
return <Warning sx={{ fontSize: 18 }} />;
|
||||||
|
case 'Low':
|
||||||
|
return <CheckCircle sx={{ fontSize: 18 }} />;
|
||||||
|
default:
|
||||||
|
return <Warning sx={{ fontSize: 18 }} />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScoreBadgeVariant = (score: number) => {
|
const getChipColor = (priority: string) => {
|
||||||
if (score >= 80) return 'success';
|
switch (priority) {
|
||||||
if (score >= 60) return 'warning';
|
case 'High':
|
||||||
return 'error';
|
return 'error';
|
||||||
|
case 'Medium':
|
||||||
|
return 'warning';
|
||||||
|
case 'Low':
|
||||||
|
return 'success';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<Lightbulb sx={{ color: 'primary.main' }} />
|
<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
|
Actionable Recommendations
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
{recommendations.map((rec, index) => (
|
{recommendations.map((rec, index) => {
|
||||||
<Paper
|
const styles = priorityStyles[rec.priority] || priorityStyles.default;
|
||||||
key={index}
|
return (
|
||||||
sx={{
|
<Paper
|
||||||
p: 3,
|
key={index}
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
sx={{
|
||||||
background: 'rgba(255,255,255,0.03)',
|
p: 3,
|
||||||
borderRadius: 2
|
background: '#ffffff',
|
||||||
}}
|
border: '1px solid #e2e8f0',
|
||||||
>
|
borderRadius: 3,
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
boxShadow: '0 16px 36px rgba(15,23,42,0.08)',
|
||||||
<Box sx={{ color: getPriorityColor(rec.priority), mt: 0.5 }}>
|
color: '#0f172a'
|
||||||
{getPriorityIcon(rec.priority)}
|
}}
|
||||||
</Box>
|
>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
<Box
|
||||||
<Chip
|
sx={{
|
||||||
label={rec.category}
|
mt: 0.5,
|
||||||
variant="outlined"
|
display: 'flex',
|
||||||
size="small"
|
alignItems: 'center',
|
||||||
sx={{ borderColor: 'rgba(255,255,255,0.3)' }}
|
justifyContent: 'center',
|
||||||
/>
|
width: 32,
|
||||||
<Chip
|
height: 32,
|
||||||
label={rec.priority}
|
borderRadius: '999px',
|
||||||
color={getScoreBadgeVariant(rec.priority === 'High' ? 30 : 70)}
|
background: styles.gradient,
|
||||||
size="small"
|
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>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
|
||||||
{rec.recommendation}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
|
||||||
{rec.impact}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Paper>
|
||||||
</Paper>
|
);
|
||||||
))}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Structure Analysis Component
|
* Structure Analysis Component
|
||||||
*
|
*
|
||||||
* Displays comprehensive content structure analysis including structure overview,
|
* Displays comprehensive content structure analysis including structure overview,
|
||||||
* content elements detection, and heading structure analysis.
|
* content elements detection, and heading structure analysis.
|
||||||
*/
|
*/
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
BarChart
|
BarChart
|
||||||
} from '@mui/icons-material';
|
} 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 }) => {
|
export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAnalysis }) => {
|
||||||
|
const structure = detailedAnalysis?.content_structure;
|
||||||
|
const quality = detailedAnalysis?.content_quality;
|
||||||
|
const headings = detailedAnalysis?.heading_structure;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<BarChart sx={{ color: 'primary.main' }} />
|
<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
|
Content Structure Analysis
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Content Structure Overview */}
|
{/* Content Structure Overview */}
|
||||||
<Grid item xs={12} md={6}>
|
<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={baseCard}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||||
Structure Overview
|
Structure Overview
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||||
Total Sections
|
Total Sections
|
||||||
</Typography>
|
</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.
|
Number of main content sections (H2 headings) in your blog post.
|
||||||
</Typography>
|
</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
|
<strong>Optimal Range:</strong> 3-8 sections for most blog posts
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||||
<strong>Why it matters:</strong> Good sectioning improves readability and helps search engines understand your content structure.
|
<strong>Why it matters:</strong> Good sectioning improves readability and structure.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
<Box sx={infoRow}>
|
||||||
<Typography variant="body2">Total Sections</Typography>
|
<Typography variant="body2" sx={statLabel}>Total Sections</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={statValue}>
|
||||||
{detailedAnalysis?.content_structure?.total_sections || 'N/A'}
|
{structure?.total_sections || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||||
Total Paragraphs
|
Total Paragraphs
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||||
Number of paragraphs in your content (excluding headings).
|
Number of paragraphs in your content (excluding headings).
|
||||||
</Typography>
|
</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
|
<strong>Optimal Range:</strong> 8-20 paragraphs for most blog posts
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
|
||||||
<strong>Why it matters:</strong> Appropriate paragraph count indicates good content depth and organization.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
<Box sx={infoRow}>
|
||||||
<Typography variant="body2">Total Paragraphs</Typography>
|
<Typography variant="body2" sx={statLabel}>Total Paragraphs</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={statValue}>
|
||||||
{detailedAnalysis?.content_structure?.total_paragraphs || 'N/A'}
|
{structure?.total_paragraphs || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||||
Total Sentences
|
Total Sentences
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1, color: '#475569' }}>
|
||||||
Total number of sentences in your content.
|
Total number of sentences in your content.
|
||||||
</Typography>
|
</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
|
<strong>Optimal Range:</strong> 40-100 sentences for most blog posts
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
|
||||||
<strong>Why it matters:</strong> Sentence count affects readability and content comprehensiveness.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
<Box sx={infoRow}>
|
||||||
<Typography variant="body2">Total Sentences</Typography>
|
<Typography variant="body2" sx={statLabel}>Total Sentences</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={statValue}>
|
||||||
{detailedAnalysis?.content_structure?.total_sentences || 'N/A'}
|
{structure?.total_sentences || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<Box sx={{ p: 1 }}>
|
<Box sx={{ p: 1 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1, color: '#0f172a' }}>
|
||||||
Structure Score
|
Structure Score
|
||||||
</Typography>
|
</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.
|
Overall score (0-100) for your content's structural organization.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
<Typography variant="caption" sx={{ display: 'block', color: '#64748b' }}>
|
||||||
<strong>Scoring Factors:</strong> Section count, paragraph count, introduction/conclusion presence
|
<strong>Scoring Factors:</strong> Section count, paragraph count, intro/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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
<Box sx={infoRow}>
|
||||||
<Typography variant="body2">Structure Score</Typography>
|
<Typography variant="body2" sx={statLabel}>Structure Score</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
<Typography variant="body2" sx={statValue}>
|
||||||
{detailedAnalysis?.content_structure?.structure_score || 'N/A'}
|
{structure?.structure_score || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -182,94 +212,52 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
|||||||
|
|
||||||
{/* Content Elements */}
|
{/* Content Elements */}
|
||||||
<Grid item xs={12} md={6}>
|
<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={baseCard}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||||
Content Elements
|
Content Elements
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Whether your content has a clear introduction that sets context and expectations."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
<Box sx={infoRow}>
|
||||||
<Typography variant="body2">Has Introduction</Typography>
|
<Typography variant="body2" sx={statLabel}>Has Introduction</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
label={detailedAnalysis?.content_structure?.has_introduction ? 'Yes' : 'No'}
|
label={structure?.has_introduction ? 'Yes' : 'No'}
|
||||||
color={detailedAnalysis?.content_structure?.has_introduction ? 'success' : 'error'}
|
color={structure?.has_introduction ? 'success' : 'error'}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Whether your content ends with a clear conclusion summarizing key points."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
<Box sx={infoRow}>
|
||||||
<Typography variant="body2">Has Conclusion</Typography>
|
<Typography variant="body2" sx={statLabel}>Has Conclusion</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
label={detailedAnalysis?.content_structure?.has_conclusion ? 'Yes' : 'No'}
|
label={structure?.has_conclusion ? 'Yes' : 'No'}
|
||||||
color={detailedAnalysis?.content_structure?.has_conclusion ? 'success' : 'error'}
|
color={structure?.has_conclusion ? 'success' : 'error'}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Whether your content includes a clear call to action for readers."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'help' }}>
|
<Box sx={infoRow}>
|
||||||
<Typography variant="body2">Has Call to Action</Typography>
|
<Typography variant="body2" sx={statLabel}>Has Call to Action</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
label={detailedAnalysis?.content_structure?.has_call_to_action ? 'Yes' : 'No'}
|
label={structure?.has_call_to_action ? 'Yes' : 'No'}
|
||||||
color={detailedAnalysis?.content_structure?.has_call_to_action ? 'success' : 'error'}
|
color={structure?.has_call_to_action ? 'success' : 'error'}
|
||||||
size="small"
|
size="small"
|
||||||
|
sx={{ fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -281,193 +269,104 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
|||||||
{/* Content Quality Metrics */}
|
{/* Content Quality Metrics */}
|
||||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||||
<Grid item xs={12}>
|
<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={baseCard}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||||
Content Quality Metrics
|
Content Quality Metrics
|
||||||
</Typography>
|
</Typography>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Total number of words in your content. Longer content typically ranks better."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
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' }}>
|
<Box sx={highlightCard('rgba(34,197,94,0.65)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
|
||||||
Word Count
|
Word Count
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||||
{detailedAnalysis?.content_quality?.word_count || 'N/A'}
|
{quality?.word_count || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Ratio of unique words to total words, indicating content variety and richness."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
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' }}>
|
<Box sx={highlightCard('rgba(59,130,246,0.65)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'primary.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
|
||||||
Vocabulary Diversity
|
Vocabulary Diversity
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||||
{detailedAnalysis?.content_quality?.vocabulary_diversity ?
|
{quality?.vocabulary_diversity !== undefined
|
||||||
(detailedAnalysis.content_quality.vocabulary_diversity * 100).toFixed(1) + '%' : 'N/A'}
|
? `${(quality.vocabulary_diversity * 100).toFixed(1)}%`
|
||||||
|
: 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Score (0-100) indicating how comprehensive and detailed your content is."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
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' }}>
|
<Box sx={highlightCard('rgba(168,85,247,0.65)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'secondary.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#7c3aed', mb: 1 }}>
|
||||||
Content Depth Score
|
Content Depth Score
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||||
{detailedAnalysis?.content_quality?.content_depth_score || 'N/A'}
|
{quality?.content_depth_score || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Score (0-100) indicating how well your content flows from one idea to the next."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
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' }}>
|
<Box sx={highlightCard('rgba(14,165,233,0.6)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'warning.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#0284c7', mb: 1 }}>
|
||||||
Flow Score
|
Flow Score
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||||
{detailedAnalysis?.content_quality?.flow_score || 'N/A'}
|
{quality?.flow_score || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Number of transition words used – higher values suggest smoother narrative flow."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
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' }}>
|
<Box sx={highlightCard('rgba(251,191,36,0.6)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'error.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#b45309', mb: 1 }}>
|
||||||
Transition Words
|
Transition Words Used
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||||
{detailedAnalysis?.content_quality?.transition_words_used || 'N/A'}
|
{quality?.transition_words_used || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title="Average unique words used throughout the article. Indicates lexical richness."
|
||||||
<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>
|
|
||||||
}
|
|
||||||
arrow
|
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' }}>
|
<Box sx={highlightCard('rgba(244,114,182,0.6)')}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'info.main', mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#be185d', mb: 1 }}>
|
||||||
Unique Words
|
Unique Words
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||||
{detailedAnalysis?.content_quality?.unique_words || 'N/A'}
|
{quality?.unique_words || 'N/A'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -478,136 +377,58 @@ export const StructureAnalysis: React.FC<StructureAnalysisProps> = ({ detailedAn
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Heading Structure */}
|
{/* Heading Structure */}
|
||||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
{headings && (
|
||||||
<Grid item xs={12}>
|
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||||
<Paper sx={{ p: 3, background: 'rgba(255,255,255,0.8)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
<Grid item xs={12}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
<Paper sx={baseCard}>
|
||||||
Heading Structure Analysis
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 2, color: '#0f172a' }}>
|
||||||
</Typography>
|
Heading Structure
|
||||||
<Grid container spacing={2}>
|
</Typography>
|
||||||
<Grid item xs={12} md={4}>
|
<Grid container spacing={2}>
|
||||||
<Box sx={{ p: 2, background: 'rgba(76, 175, 80, 0.1)', borderRadius: 2, border: '1px solid rgba(76, 175, 80, 0.3)' }}>
|
<Grid item xs={12} md={4}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'success.main', mb: 1 }}>
|
<Box sx={highlightCard('rgba(59,130,246,0.45)')}>
|
||||||
H1 Headings ({detailedAnalysis?.heading_structure?.h1_count || 0})
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#1d4ed8', mb: 1 }}>
|
||||||
</Typography>
|
H1 Headings
|
||||||
{detailedAnalysis?.heading_structure?.h1_headings?.map((heading: string, index: number) => (
|
|
||||||
<Typography key={index} variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
|
||||||
• {heading}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||||
</Box>
|
{headings.h1_count}
|
||||||
</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>
|
</Typography>
|
||||||
))}
|
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||||
{detailedAnalysis?.heading_structure?.h2_headings && detailedAnalysis.heading_structure.h2_headings.length > 3 && (
|
{headings.h1_headings?.[0] ? `Primary: ${headings.h1_headings[0]}` : 'Primary heading analysis'}
|
||||||
<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>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
</Grid>
|
||||||
arrow
|
<Grid item xs={12} md={4}>
|
||||||
>
|
<Box sx={highlightCard('rgba(34,197,94,0.45)')}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, cursor: 'help' }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: '#15803d', mb: 1 }}>
|
||||||
Heading Hierarchy Score: {detailedAnalysis?.heading_structure?.heading_hierarchy_score || 'N/A'}
|
H2 Headings
|
||||||
</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}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
<Typography variant="h6" sx={{ fontWeight: 800, color: '#0f172a' }}>
|
||||||
</Box>
|
{headings.h2_count}
|
||||||
</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>
|
</Typography>
|
||||||
))}
|
<Typography variant="caption" sx={{ color: '#64748b' }}>
|
||||||
</Box>
|
{headings.h2_headings?.slice(0, 2).join(', ') || 'Summary of subtopics'}
|
||||||
</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>
|
</Typography>
|
||||||
))}
|
</Box>
|
||||||
</Box>
|
</Grid>
|
||||||
</Box>
|
<Grid item xs={12} md={4}>
|
||||||
)}
|
<Box sx={highlightCard('rgba(14,165,233,0.45)')}>
|
||||||
</Paper>
|
<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>
|
||||||
</Grid>
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import {
|
|||||||
Grid,
|
Grid,
|
||||||
Paper,
|
Paper,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip
|
Tooltip,
|
||||||
|
Avatar,
|
||||||
|
CircularProgress
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { apiClient } from '../../api/client';
|
import { apiClient } from '../../api/client';
|
||||||
import {
|
import {
|
||||||
@@ -32,11 +34,11 @@ import {
|
|||||||
Warning,
|
Warning,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Search,
|
Search,
|
||||||
BarChart,
|
|
||||||
Refresh,
|
Refresh,
|
||||||
Close
|
Close
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
|
import { KeywordAnalysis, ReadabilityAnalysis, StructureAnalysis, Recommendations } from './SEO';
|
||||||
|
import OverallScoreCard from './SEO/OverallScoreCard';
|
||||||
|
|
||||||
interface SEOAnalysisResult {
|
interface SEOAnalysisResult {
|
||||||
overall_score: number;
|
overall_score: number;
|
||||||
@@ -139,7 +141,27 @@ interface SEOAnalysisModalProps {
|
|||||||
blogContent: string;
|
blogContent: string;
|
||||||
blogTitle?: string;
|
blogTitle?: string;
|
||||||
researchData: any;
|
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> = ({
|
export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||||
@@ -148,7 +170,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
blogContent,
|
blogContent,
|
||||||
blogTitle,
|
blogTitle,
|
||||||
researchData,
|
researchData,
|
||||||
onApplyRecommendations
|
onApplyRecommendations,
|
||||||
|
onAnalysisComplete
|
||||||
}) => {
|
}) => {
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
|
const [analysisResult, setAnalysisResult] = useState<SEOAnalysisResult | null>(null);
|
||||||
@@ -156,18 +179,37 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
const [progressMessage, setProgressMessage] = useState('');
|
const [progressMessage, setProgressMessage] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tabValue, setTabValue] = useState('recommendations');
|
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 });
|
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||||
|
|
||||||
const runSEOAnalysis = useCallback(async () => {
|
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
|
||||||
try {
|
try {
|
||||||
setIsAnalyzing(true);
|
setIsAnalyzing(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setProgressMessage('Starting SEO analysis...');
|
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 = [
|
const progressStages = [
|
||||||
{ progress: 20, message: 'Extracting keywords from research data...' },
|
{ progress: 20, message: 'Extracting keywords from research data...' },
|
||||||
{ progress: 40, message: 'Analyzing content structure and readability...' },
|
{ 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));
|
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', {
|
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
|
||||||
blog_content: blogContent,
|
blog_content: blogContent,
|
||||||
blog_title: blogTitle,
|
blog_title: blogTitle,
|
||||||
@@ -191,15 +233,8 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
|
|
||||||
const result = response.data;
|
const result = response.data;
|
||||||
console.log('🔍 Backend SEO Analysis Response:', result);
|
console.log('🔍 Backend SEO Analysis Response:', result);
|
||||||
|
if (!result.success) throw new Error(result.recommendations?.[0] || 'SEO analysis failed');
|
||||||
// Convert API response to frontend format - fail fast if data is missing
|
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 = {
|
const convertedResult: SEOAnalysisResult = {
|
||||||
overall_score: result.overall_score,
|
overall_score: result.overall_score,
|
||||||
@@ -256,13 +291,44 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
setAnalysisResult(convertedResult);
|
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);
|
setIsAnalyzing(false);
|
||||||
|
|
||||||
|
// Notify parent that analysis is complete (fresh analysis)
|
||||||
|
if (onAnalysisComplete) {
|
||||||
|
onAnalysisComplete(convertedResult);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Analysis failed');
|
setError(err instanceof Error ? err.message : 'Analysis failed');
|
||||||
setIsAnalyzing(false);
|
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) => {
|
const getScoreColor = (score: number) => {
|
||||||
if (score >= 80) return 'success.main';
|
if (score >= 80) return 'success.main';
|
||||||
@@ -270,13 +336,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
return 'error.main';
|
return 'error.main';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getScoreBadgeVariant = (score: number) => {
|
|
||||||
if (score >= 80) return 'success';
|
|
||||||
if (score >= 60) return 'warning';
|
|
||||||
return 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Tooltip content for each metric
|
// Tooltip content for each metric
|
||||||
const getMetricTooltip = (category: string) => {
|
const getMetricTooltip = (category: string) => {
|
||||||
const tooltips = {
|
const tooltips = {
|
||||||
@@ -326,12 +385,6 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
|
return tooltips[category as keyof typeof tooltips] || tooltips.structure;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && !analysisResult) {
|
|
||||||
runSEOAnalysis();
|
|
||||||
}
|
|
||||||
}, [isOpen, analysisResult, runSEOAnalysis]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
@@ -342,14 +395,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
sx: {
|
sx: {
|
||||||
maxHeight: '90vh',
|
maxHeight: '90vh',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
background: 'rgba(255, 255, 255, 0.98)',
|
backgroundColor: '#f8fafc',
|
||||||
backdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)',
|
||||||
border: '1px solid rgba(0,0,0,0.1)',
|
border: '1px solid rgba(148,163,184,0.25)',
|
||||||
color: 'text.primary'
|
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={{ 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', justifyContent: 'space-between' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
@@ -358,9 +411,22 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
SEO Analysis Results
|
SEO Analysis Results
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Close />
|
<Button
|
||||||
</IconButton>
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Refresh />}
|
||||||
|
onClick={() => {
|
||||||
|
setAnalysisResult(null);
|
||||||
|
runSEOAnalysis(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<IconButton onClick={onClose} sx={{ color: 'text.secondary' }}>
|
||||||
|
<Close />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||||
Comprehensive analysis of your blog content's SEO optimization
|
Comprehensive analysis of your blog content's SEO optimization
|
||||||
@@ -410,138 +476,14 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
{analysisResult && (
|
{analysisResult && (
|
||||||
<Box sx={{ p: 3 }}>
|
<Box sx={{ p: 3 }}>
|
||||||
{/* Overall Score Section */}
|
{/* Overall Score Section */}
|
||||||
<Card sx={{ mb: 3, background: 'rgba(255,255,255,0.9)', border: '1px solid rgba(0,0,0,0.1)' }}>
|
<OverallScoreCard
|
||||||
<CardHeader>
|
overallScore={analysisResult.overall_score}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
overallGrade={analysisResult.analysis_summary.overall_grade}
|
||||||
<BarChart sx={{ color: 'primary.main' }} />
|
statusLabel={analysisResult.analysis_summary.status}
|
||||||
<Typography variant="h6" component="h3" sx={{ fontWeight: 600 }}>
|
categoryScores={analysisResult.category_scores}
|
||||||
Overall SEO Score
|
getMetricTooltip={getMetricTooltip}
|
||||||
</Typography>
|
getScoreColor={getScoreColor}
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Detailed Analysis Tabs */}
|
{/* Detailed Analysis Tabs */}
|
||||||
<Card sx={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)' }}>
|
<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>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<TrendingUp sx={{ color: 'primary.main' }} />
|
<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
|
AI-Powered Insights
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<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)' }}>
|
<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: 600, mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1.5 }}>
|
||||||
Content Summary
|
Content Summary
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
<Typography variant="body2" sx={{ color: '#475569', lineHeight: 1.6 }}>
|
||||||
{analysisResult.analysis_summary.ai_summary}
|
{analysisResult.analysis_summary.ai_summary}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||||
<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: 700, mb: 1.5 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
|
||||||
Key Strengths
|
Key Strengths
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
|
{analysisResult.analysis_summary.key_strengths.map((strength, index) => (
|
||||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
<CheckCircle sx={{ color: 'success.main', fontSize: 16 }} />
|
||||||
<Typography variant="body2">{strength}</Typography>
|
<Typography variant="body2" sx={{ color: '#1f2937' }}>{strength}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
<Paper sx={{ p: 3, backgroundColor: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 3, boxShadow: '0 12px 28px rgba(15,23,42,0.08)', color: '#0f172a' }}>
|
||||||
<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: 700, mb: 1.5 }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
|
|
||||||
Areas for Improvement
|
Areas for Improvement
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
|
{analysisResult.analysis_summary.key_weaknesses.map((weakness, index) => (
|
||||||
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box key={index} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
|
<Warning sx={{ color: 'warning.main', fontSize: 16 }} />
|
||||||
<Typography variant="body2">{weakness}</Typography>
|
<Typography variant="body2" sx={{ color: '#1f2937' }}>{weakness}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -652,19 +592,35 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<Box sx={{ p: 3, borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
<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 }}>
|
<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
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (onApplyRecommendations) {
|
if (!onApplyRecommendations) return;
|
||||||
onApplyRecommendations(analysisResult.actionable_recommendations);
|
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={{
|
sx={{
|
||||||
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
background: 'linear-gradient(45deg, #4caf50, #8bc34a)',
|
||||||
'&:hover': {
|
'&: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>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* - Integration with backend metadata generation
|
* - Integration with backend metadata generation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
CircularProgress,
|
CircularProgress,
|
||||||
Alert,
|
Alert,
|
||||||
IconButton,
|
IconButton,
|
||||||
Chip
|
Chip,
|
||||||
|
Tooltip
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Close as CloseIcon,
|
Close as CloseIcon,
|
||||||
@@ -42,6 +43,7 @@ import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab';
|
|||||||
import { SocialMediaTab } from './SEO/MetadataDisplay/SocialMediaTab';
|
import { SocialMediaTab } from './SEO/MetadataDisplay/SocialMediaTab';
|
||||||
import { StructuredDataTab } from './SEO/MetadataDisplay/StructuredDataTab';
|
import { StructuredDataTab } from './SEO/MetadataDisplay/StructuredDataTab';
|
||||||
import { PreviewCard } from './SEO/MetadataDisplay/PreviewCard';
|
import { PreviewCard } from './SEO/MetadataDisplay/PreviewCard';
|
||||||
|
import { subscribeImage } from '../../utils/imageBus';
|
||||||
|
|
||||||
interface SEOMetadataModalProps {
|
interface SEOMetadataModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -49,6 +51,8 @@ interface SEOMetadataModalProps {
|
|||||||
blogContent: string;
|
blogContent: string;
|
||||||
blogTitle: string;
|
blogTitle: string;
|
||||||
researchData: any;
|
researchData: any;
|
||||||
|
outline?: any[]; // Add outline structure
|
||||||
|
seoAnalysis?: any; // Add SEO analysis results
|
||||||
onMetadataGenerated: (metadata: any) => void;
|
onMetadataGenerated: (metadata: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,20 +75,55 @@ interface SEOMetadataResult {
|
|||||||
error?: string;
|
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> = ({
|
export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
blogContent,
|
blogContent,
|
||||||
blogTitle,
|
blogTitle,
|
||||||
researchData,
|
researchData,
|
||||||
|
outline,
|
||||||
|
seoAnalysis,
|
||||||
onMetadataGenerated
|
onMetadataGenerated
|
||||||
}) => {
|
}) => {
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [metadataResult, setMetadataResult] = useState<SEOMetadataResult | null>(null);
|
const [metadataResult, setMetadataResult] = useState<SEOMetadataResult | null>(null);
|
||||||
const [error, setError] = useState<string | 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 [copiedItems, setCopiedItems] = useState<Set<string>>(new Set());
|
||||||
const [editableMetadata, setEditableMetadata] = useState<SEOMetadataResult | null>(null);
|
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
|
// Debug logging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -96,19 +135,67 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
|||||||
});
|
});
|
||||||
}, [isOpen, blogContent, blogTitle, researchData]);
|
}, [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 {
|
try {
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setError(null);
|
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
|
// Make API call to generate metadata
|
||||||
const response = await apiClient.post('/api/blog/seo/metadata', {
|
const response = await apiClient.post('/api/blog/seo/metadata', {
|
||||||
content: blogContent,
|
content: blogContent,
|
||||||
title: blogTitle,
|
title: blogTitle,
|
||||||
research_data: researchData
|
research_data: researchData,
|
||||||
|
outline: outline || null,
|
||||||
|
seo_analysis: seoAnalysis || null
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = response.data;
|
const result = response.data;
|
||||||
@@ -118,6 +205,16 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
|||||||
throw new Error(result.error || 'Metadata generation failed');
|
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);
|
setMetadataResult(result);
|
||||||
setEditableMetadata(result);
|
setEditableMetadata(result);
|
||||||
console.log('📊 Metadata result set:', result);
|
console.log('📊 Metadata result set:', result);
|
||||||
@@ -128,7 +225,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
}, [blogContent, blogTitle, researchData, outline, seoAnalysis]);
|
||||||
|
|
||||||
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
|
||||||
setTabValue(newValue);
|
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 = () => {
|
const handleApplyMetadata = () => {
|
||||||
if (editableMetadata) {
|
if (editableMetadata) {
|
||||||
onMetadataGenerated(editableMetadata);
|
onMetadataGenerated(editableMetadata);
|
||||||
@@ -222,32 +336,26 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton onClick={onClose} size="small">
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<CloseIcon />
|
{metadataResult && (
|
||||||
</IconButton>
|
<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>
|
</DialogTitle>
|
||||||
|
|
||||||
<DialogContent sx={{ p: 0 }}>
|
<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 && (
|
{isGenerating && (
|
||||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||||
<CircularProgress size={60} sx={{ mb: 2 }} />
|
<CircularProgress size={60} sx={{ mb: 2 }} />
|
||||||
@@ -267,7 +375,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
|||||||
</Alert>
|
</Alert>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={generateMetadata}
|
onClick={() => generateMetadata(true)}
|
||||||
startIcon={<RefreshIcon />}
|
startIcon={<RefreshIcon />}
|
||||||
>
|
>
|
||||||
Try Again
|
Try Again
|
||||||
@@ -286,7 +394,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
|||||||
scrollButtons="auto"
|
scrollButtons="auto"
|
||||||
sx={{ minHeight: 48 }}
|
sx={{ minHeight: 48 }}
|
||||||
>
|
>
|
||||||
{['core', 'social', 'structured', 'preview'].map((tab) => (
|
{['preview', 'core', 'social', 'structured'].map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab}
|
key={tab}
|
||||||
value={tab}
|
value={tab}
|
||||||
@@ -332,6 +440,8 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
|||||||
<PreviewCard
|
<PreviewCard
|
||||||
metadata={editableMetadata || metadataResult}
|
metadata={editableMetadata || metadataResult}
|
||||||
blogTitle={blogTitle}
|
blogTitle={blogTitle}
|
||||||
|
previewTabValue={previewTabValue}
|
||||||
|
onPreviewTabChange={setPreviewTabValue}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ const SEOMiniPanel: React.FC<Props> = ({ analysis }) => {
|
|||||||
return (
|
return (
|
||||||
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
|
<div style={{ border: '1px solid #eee', padding: 8, marginTop: 8 }}>
|
||||||
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
|
<div style={{ fontWeight: 600 }}>SEO Mini Panel</div>
|
||||||
<div>Score: {analysis.seo_score}</div>
|
<div>Score: {analysis.overall_score}</div>
|
||||||
{!!analysis.recommendations?.length && (
|
{!!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>
|
<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>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,17 +13,35 @@ interface SuggestionsGeneratorProps {
|
|||||||
contentConfirmed?: boolean;
|
contentConfirmed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSuggestions = (
|
interface SuggestionContext {
|
||||||
research: BlogResearchResponse | null,
|
research: BlogResearchResponse | null;
|
||||||
outline: BlogOutlineSection[],
|
outline: BlogOutlineSection[];
|
||||||
outlineConfirmed: boolean = false,
|
outlineConfirmed?: boolean;
|
||||||
researchPolling?: { isPolling: boolean; currentStatus: string },
|
researchPolling?: { isPolling: boolean; currentStatus: string };
|
||||||
outlinePolling?: { isPolling: boolean; currentStatus: string },
|
outlinePolling?: { isPolling: boolean; currentStatus: string };
|
||||||
mediumPolling?: { isPolling: boolean; currentStatus: string },
|
mediumPolling?: { isPolling: boolean; currentStatus: string };
|
||||||
hasContent: boolean = false,
|
hasContent?: boolean;
|
||||||
flowAnalysisCompleted: boolean = false,
|
flowAnalysisCompleted?: boolean;
|
||||||
contentConfirmed: boolean = false
|
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(() => {
|
return useMemo(() => {
|
||||||
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
|
const items = [] as { title: string; message: string; priority?: 'high' | 'normal' }[];
|
||||||
|
|
||||||
@@ -66,14 +84,14 @@ export const useSuggestions = (
|
|||||||
if (!research) {
|
if (!research) {
|
||||||
items.push({
|
items.push({
|
||||||
title: '🔎 Start Research',
|
title: '🔎 Start Research',
|
||||||
message: "I want to research a topic for my blog",
|
message: "showResearchForm",
|
||||||
priority: 'high'
|
priority: 'high'
|
||||||
});
|
});
|
||||||
} else if (research && outline.length === 0) {
|
} else if (research && outline.length === 0) {
|
||||||
// Research completed, guide user to outline creation
|
// Research completed, guide user to outline creation
|
||||||
items.push({
|
items.push({
|
||||||
title: 'Next: Create Outline',
|
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'
|
priority: 'high'
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
@@ -82,13 +100,13 @@ export const useSuggestions = (
|
|||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
title: '🎨 Create Custom Outline',
|
title: '🎨 Create Custom Outline',
|
||||||
message: 'I want to create an outline with my own specific instructions and requirements'
|
message: 'I want to create an outline with my own specific instructions and requirements. Please ask me for my custom requirements.'
|
||||||
});
|
});
|
||||||
} else if (outline.length > 0 && !outlineConfirmed) {
|
} else if (outline.length > 0 && !outlineConfirmed) {
|
||||||
// Outline created but not confirmed - focus on outline review and confirmation
|
// Outline created but not confirmed - focus on outline review and confirmation
|
||||||
items.push({
|
items.push({
|
||||||
title: 'Next: Confirm & Generate Content',
|
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'
|
priority: 'high'
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
@@ -106,12 +124,6 @@ export const useSuggestions = (
|
|||||||
} else if (outline.length > 0 && outlineConfirmed) {
|
} else if (outline.length > 0 && outlineConfirmed) {
|
||||||
// Outline confirmed, focus on content generation and optimization
|
// Outline confirmed, focus on content generation and optimization
|
||||||
if (hasContent && !contentConfirmed) {
|
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({
|
items.push({
|
||||||
title: '🔄 ReWrite Blog',
|
title: '🔄 ReWrite Blog',
|
||||||
message: 'I want to rewrite my blog with different approach, tone, or focus'
|
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'
|
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
title: '📈 Run SEO Analysis',
|
title: 'Next: Run SEO Analysis',
|
||||||
message: 'Analyze SEO for my blog post'
|
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
|
||||||
});
|
});
|
||||||
} else if (hasContent && contentConfirmed) {
|
} else if (hasContent && contentConfirmed) {
|
||||||
// Content confirmed - move to SEO stage
|
if (!seoAnalysis) {
|
||||||
items.push({
|
// Prompt to run SEO analysis first
|
||||||
title: '📈 Run SEO Analysis',
|
items.push({
|
||||||
message: 'Analyze SEO for my blog post',
|
title: 'Next: Run SEO Analysis',
|
||||||
priority: 'high'
|
message: 'The blog content is confirmed. Execute analyzeSEO immediately to launch the SEO analysis modal without further prompts.',
|
||||||
});
|
priority: 'high'
|
||||||
items.push({
|
});
|
||||||
title: '🧾 Generate SEO Metadata',
|
items.push({
|
||||||
message: 'Generate SEO metadata and title'
|
title: 'Content Analysis',
|
||||||
});
|
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||||
items.push({
|
});
|
||||||
title: '🚀 Publish to WordPress',
|
items.push({
|
||||||
message: 'Publish my blog to WordPress'
|
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 {
|
} else {
|
||||||
// No content yet, show generation option
|
// No content yet, show generation option
|
||||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||||
@@ -146,11 +212,24 @@ export const useSuggestions = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
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 }) => {
|
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
|
return null; // This is just a utility component
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,21 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
|||||||
// Handle text replacement in the textarea
|
// Handle text replacement in the textarea
|
||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
const textarea = 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);
|
setContent(updatedContent);
|
||||||
|
|
||||||
// Update parent state
|
// Update parent state
|
||||||
@@ -79,14 +92,8 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
|||||||
onContentUpdate([{ id, content: updatedContent }]);
|
onContentUpdate([{ id, content: updatedContent }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus back to textarea and set cursor after the replaced text
|
// Note: Cursor positioning is handled by SmartTypingAssist for smart-suggestion edits
|
||||||
setTimeout(() => {
|
// For other edits, we may need to handle cursor positioning here if needed
|
||||||
if (contentRef.current) {
|
|
||||||
const newCursorPosition = updatedContent.indexOf(newText) + newText.length;
|
|
||||||
contentRef.current.focus();
|
|
||||||
contentRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
import { hallucinationDetectorService, HallucinationDetectionResponse } from '../../../services/hallucinationDetectorService';
|
||||||
import TextSelectionMenu from './TextSelectionMenu';
|
import TextSelectionMenu from './TextSelectionMenu';
|
||||||
import useSmartTypingAssist from './SmartTypingAssist';
|
import useSmartTypingAssist from './SmartTypingAssist';
|
||||||
|
import { debug } from '../../../utils/debug';
|
||||||
|
|
||||||
interface BlogTextSelectionHandlerProps {
|
interface BlogTextSelectionHandlerProps {
|
||||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||||
@@ -281,12 +282,15 @@ const useBlogTextSelectionHandler = (
|
|||||||
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
|
isGeneratingSuggestion={smartTypingAssist.isGeneratingSuggestion}
|
||||||
allSuggestions={smartTypingAssist.allSuggestions}
|
allSuggestions={smartTypingAssist.allSuggestions}
|
||||||
suggestionIndex={smartTypingAssist.suggestionIndex}
|
suggestionIndex={smartTypingAssist.suggestionIndex}
|
||||||
|
showContinueWritingPrompt={smartTypingAssist.showContinueWritingPrompt}
|
||||||
onCheckFacts={handleCheckFacts}
|
onCheckFacts={handleCheckFacts}
|
||||||
onCloseFactCheckResults={handleCloseFactCheckResults}
|
onCloseFactCheckResults={handleCloseFactCheckResults}
|
||||||
onQuickEdit={handleQuickEdit}
|
onQuickEdit={handleQuickEdit}
|
||||||
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
|
onAcceptSuggestion={smartTypingAssist.handleAcceptSuggestion}
|
||||||
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
|
onRejectSuggestion={smartTypingAssist.handleRejectSuggestion}
|
||||||
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
|
onNextSuggestion={smartTypingAssist.handleNextSuggestion}
|
||||||
|
onRequestSuggestion={smartTypingAssist.handleRequestSuggestion}
|
||||||
|
onDismissPrompt={smartTypingAssist.handleDismissPrompt}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { debug } from '../../../utils/debug';
|
||||||
|
|
||||||
interface SmartTypingAssistProps {
|
interface SmartTypingAssistProps {
|
||||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||||
@@ -40,7 +41,9 @@ const useSmartTypingAssist = (
|
|||||||
const [allSuggestions, setAllSuggestions] = useState<Suggestion[]>([]);
|
const [allSuggestions, setAllSuggestions] = useState<Suggestion[]>([]);
|
||||||
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
|
const [isGeneratingSuggestion, setIsGeneratingSuggestion] = useState(false);
|
||||||
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
|
const [hasShownFirstSuggestion, setHasShownFirstSuggestion] = useState(false);
|
||||||
|
const [showContinueWritingPrompt, setShowContinueWritingPrompt] = useState(false);
|
||||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const lastGeneratedAtRef = useRef<number>(0);
|
||||||
|
|
||||||
// Quality improvement tracking
|
// Quality improvement tracking
|
||||||
const [suggestionStats, setSuggestionStats] = useState({
|
const [suggestionStats, setSuggestionStats] = useState({
|
||||||
@@ -52,25 +55,25 @@ const useSmartTypingAssist = (
|
|||||||
|
|
||||||
// Smart typing assist functionality
|
// Smart typing assist functionality
|
||||||
const generateSmartSuggestion = async (currentText: string) => {
|
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) {
|
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
|
return; // Only suggest after some meaningful content
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔍 [SmartTypingAssist] Starting suggestion generation...');
|
debug.log('[SmartTypingAssist] Starting suggestion generation...');
|
||||||
setIsGeneratingSuggestion(true);
|
setIsGeneratingSuggestion(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Import the assistive writing API
|
// Import the assistive writing API
|
||||||
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
|
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
|
const response = await assistiveWritingApi.getSuggestion(currentText, 3); // Get 3 suggestions
|
||||||
|
|
||||||
if (response.success && response.suggestions.length > 0) {
|
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
|
// Store all suggestions
|
||||||
setAllSuggestions(response.suggestions);
|
setAllSuggestions(response.suggestions);
|
||||||
@@ -78,7 +81,7 @@ const useSmartTypingAssist = (
|
|||||||
|
|
||||||
// Show first suggestion
|
// Show first suggestion
|
||||||
const firstSuggestion = response.suggestions[0];
|
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
|
// Track suggestion shown
|
||||||
setSuggestionStats(prev => ({
|
setSuggestionStats(prev => ({
|
||||||
@@ -86,12 +89,30 @@ const useSmartTypingAssist = (
|
|||||||
totalShown: prev.totalShown + 1
|
totalShown: prev.totalShown + 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get cursor position for suggestion placement
|
// Get viewport-safe position for suggestion placement
|
||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
const element = contentRef.current;
|
const element = contentRef.current;
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - 420)); // Ensure it stays on screen
|
const maxWidth = 420;
|
||||||
const y = Math.max(20, rect.bottom + 10);
|
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({
|
setSmartSuggestion({
|
||||||
text: firstSuggestion.text,
|
text: firstSuggestion.text,
|
||||||
@@ -101,7 +122,7 @@ const useSmartTypingAssist = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('🔍 [SmartTypingAssist] No suggestions received from API');
|
debug.log('[SmartTypingAssist] No suggestions received from API');
|
||||||
// Fallback to generic suggestions if API fails
|
// Fallback to generic suggestions if API fails
|
||||||
const fallbackSuggestions = [
|
const fallbackSuggestions = [
|
||||||
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
|
"This approach provides significant value to readers by offering actionable insights they can implement immediately.",
|
||||||
@@ -116,8 +137,26 @@ const useSmartTypingAssist = (
|
|||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
const element = contentRef.current;
|
const element = contentRef.current;
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const x = rect.left + 20;
|
const maxWidth = 420;
|
||||||
const y = rect.bottom + 5;
|
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({
|
setSmartSuggestion({
|
||||||
text: randomSuggestion,
|
text: randomSuggestion,
|
||||||
@@ -126,7 +165,7 @@ const useSmartTypingAssist = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// Fallback to generic suggestions on error
|
||||||
const fallbackSuggestions = [
|
const fallbackSuggestions = [
|
||||||
@@ -142,8 +181,14 @@ const useSmartTypingAssist = (
|
|||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
const element = contentRef.current;
|
const element = contentRef.current;
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const x = rect.left + 20;
|
const maxWidth = 420;
|
||||||
const y = rect.bottom + 5;
|
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({
|
setSmartSuggestion({
|
||||||
text: randomSuggestion,
|
text: randomSuggestion,
|
||||||
@@ -156,7 +201,7 @@ const useSmartTypingAssist = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTypingChange = (newText: string) => {
|
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
|
// Clear existing timeout
|
||||||
if (typingTimeoutRef.current) {
|
if (typingTimeoutRef.current) {
|
||||||
@@ -168,29 +213,45 @@ const useSmartTypingAssist = (
|
|||||||
|
|
||||||
// Set new timeout for suggestion generation
|
// Set new timeout for suggestion generation
|
||||||
typingTimeoutRef.current = setTimeout(() => {
|
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
|
const cooldownMs = 15000; // 15s cooldown between suggestions
|
||||||
if (!hasShownFirstSuggestion && newText.length > 20) {
|
const now = Date.now();
|
||||||
console.log('🔍 [SmartTypingAssist] Generating first suggestion');
|
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);
|
generateSmartSuggestion(newText);
|
||||||
setHasShownFirstSuggestion(true);
|
setHasShownFirstSuggestion(true);
|
||||||
|
lastGeneratedAtRef.current = now;
|
||||||
}
|
}
|
||||||
// After first time, only suggest after longer pauses or more content
|
// After first time, show "Continue writing" prompt instead of random suggestions
|
||||||
else if (hasShownFirstSuggestion && newText.length > 50 && Math.random() > 0.7) {
|
else if (hasShownFirstSuggestion && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingSuggestion && !smartSuggestion) {
|
||||||
console.log('🔍 [SmartTypingAssist] Generating subsequent suggestion');
|
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
|
||||||
generateSmartSuggestion(newText);
|
setShowContinueWritingPrompt(true);
|
||||||
} else {
|
|
||||||
console.log('🔍 [SmartTypingAssist] No suggestion generated - conditions not met');
|
|
||||||
}
|
}
|
||||||
|
// Removed verbose log about skipping prompts as it's too noisy
|
||||||
}, 3000); // 3 second pause before suggesting
|
}, 3000); // 3 second pause before suggesting
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAcceptSuggestion = () => {
|
const handleAcceptSuggestion = () => {
|
||||||
if (smartSuggestion && onTextReplace && contentRef.current) {
|
if (smartSuggestion && onTextReplace && contentRef.current) {
|
||||||
const element = contentRef.current;
|
const element = contentRef.current as HTMLTextAreaElement;
|
||||||
const currentContent = (element as HTMLTextAreaElement).value || (element as HTMLDivElement).textContent || '';
|
const currentContent = element.value || '';
|
||||||
const newContent = currentContent + ' ' + smartSuggestion.text;
|
|
||||||
|
// 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
|
// Track suggestion accepted
|
||||||
setSuggestionStats(prev => ({
|
setSuggestionStats(prev => ({
|
||||||
@@ -198,14 +259,21 @@ const useSmartTypingAssist = (
|
|||||||
totalAccepted: prev.totalAccepted + 1
|
totalAccepted: prev.totalAccepted + 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('🔍 [SmartTypingAssist] Suggestion accepted! Stats:', {
|
debug.log('[SmartTypingAssist] Suggestion accepted', { cursorPosition, newContentLength: newContent.length, newCursorPosition });
|
||||||
...suggestionStats,
|
|
||||||
totalAccepted: suggestionStats.totalAccepted + 1
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use the text replacement callback
|
// Use the text replacement callback
|
||||||
onTextReplace(currentContent, newContent, 'smart-suggestion');
|
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);
|
setSmartSuggestion(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -217,10 +285,7 @@ const useSmartTypingAssist = (
|
|||||||
totalRejected: prev.totalRejected + 1
|
totalRejected: prev.totalRejected + 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('🔍 [SmartTypingAssist] Suggestion rejected! Stats:', {
|
debug.log('[SmartTypingAssist] Suggestion rejected', { stats: { ...suggestionStats, totalRejected: suggestionStats.totalRejected + 1 } });
|
||||||
...suggestionStats,
|
|
||||||
totalRejected: suggestionStats.totalRejected + 1
|
|
||||||
});
|
|
||||||
|
|
||||||
setSmartSuggestion(null);
|
setSmartSuggestion(null);
|
||||||
setAllSuggestions([]);
|
setAllSuggestions([]);
|
||||||
@@ -238,11 +303,8 @@ const useSmartTypingAssist = (
|
|||||||
totalCycled: prev.totalCycled + 1
|
totalCycled: prev.totalCycled + 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('🔍 [SmartTypingAssist] Showing next suggestion:', nextIndex + 1, 'of', allSuggestions.length);
|
debug.log('[SmartTypingAssist] Showing next suggestion', { index: nextIndex + 1, total: allSuggestions.length });
|
||||||
console.log('🔍 [SmartTypingAssist] Suggestion cycled! Stats:', {
|
debug.log('[SmartTypingAssist] Suggestion cycled', { stats: { ...suggestionStats, totalCycled: suggestionStats.totalCycled + 1 } });
|
||||||
...suggestionStats,
|
|
||||||
totalCycled: suggestionStats.totalCycled + 1
|
|
||||||
});
|
|
||||||
|
|
||||||
setSuggestionIndex(nextIndex);
|
setSuggestionIndex(nextIndex);
|
||||||
setSmartSuggestion(prev => prev ? {
|
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
|
// Get suggestion statistics for quality improvement
|
||||||
const getSuggestionStats = () => {
|
const getSuggestionStats = () => {
|
||||||
const acceptanceRate = suggestionStats.totalShown > 0
|
const acceptanceRate = suggestionStats.totalShown > 0
|
||||||
@@ -284,10 +365,13 @@ const useSmartTypingAssist = (
|
|||||||
allSuggestions,
|
allSuggestions,
|
||||||
suggestionIndex,
|
suggestionIndex,
|
||||||
suggestionStats: getSuggestionStats(),
|
suggestionStats: getSuggestionStats(),
|
||||||
|
showContinueWritingPrompt,
|
||||||
handleTypingChange,
|
handleTypingChange,
|
||||||
handleAcceptSuggestion,
|
handleAcceptSuggestion,
|
||||||
handleRejectSuggestion,
|
handleRejectSuggestion,
|
||||||
handleNextSuggestion,
|
handleNextSuggestion,
|
||||||
|
handleRequestSuggestion,
|
||||||
|
handleDismissPrompt,
|
||||||
getSuggestionStats,
|
getSuggestionStats,
|
||||||
generateSmartSuggestion
|
generateSmartSuggestion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ interface TextSelectionMenuProps {
|
|||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
suggestionIndex: number;
|
suggestionIndex: number;
|
||||||
|
showContinueWritingPrompt: boolean;
|
||||||
onCheckFacts: (text: string) => void;
|
onCheckFacts: (text: string) => void;
|
||||||
onCloseFactCheckResults: () => void;
|
onCloseFactCheckResults: () => void;
|
||||||
onQuickEdit: (editType: string, selectedText: string) => void;
|
onQuickEdit: (editType: string, selectedText: string) => void;
|
||||||
onAcceptSuggestion: () => void;
|
onAcceptSuggestion: () => void;
|
||||||
onRejectSuggestion: () => void;
|
onRejectSuggestion: () => void;
|
||||||
onNextSuggestion: () => void;
|
onNextSuggestion: () => void;
|
||||||
|
onRequestSuggestion: () => void;
|
||||||
|
onDismissPrompt: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
||||||
@@ -51,12 +54,15 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
|||||||
isGeneratingSuggestion,
|
isGeneratingSuggestion,
|
||||||
allSuggestions,
|
allSuggestions,
|
||||||
suggestionIndex,
|
suggestionIndex,
|
||||||
|
showContinueWritingPrompt,
|
||||||
onCheckFacts,
|
onCheckFacts,
|
||||||
onCloseFactCheckResults,
|
onCloseFactCheckResults,
|
||||||
onQuickEdit,
|
onQuickEdit,
|
||||||
onAcceptSuggestion,
|
onAcceptSuggestion,
|
||||||
onRejectSuggestion,
|
onRejectSuggestion,
|
||||||
onNextSuggestion
|
onNextSuggestion,
|
||||||
|
onRequestSuggestion,
|
||||||
|
onDismissPrompt
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -387,8 +393,10 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
|||||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
|
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25)',
|
||||||
backdropFilter: 'blur(12px)',
|
backdropFilter: 'blur(12px)',
|
||||||
zIndex: 10002,
|
zIndex: 10002,
|
||||||
maxWidth: '400px',
|
maxWidth: '420px',
|
||||||
minWidth: '320px',
|
minWidth: '320px',
|
||||||
|
maxHeight: '350px',
|
||||||
|
overflow: 'auto',
|
||||||
color: 'white'
|
color: 'white'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -540,6 +548,93 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
|||||||
</div>
|
</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 */}
|
{/* CSS for spinner animation */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes spin {
|
@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 };
|
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 {
|
export interface BlogSEOAnalyzeResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
seo_score: number;
|
analysis_id?: string;
|
||||||
density: Record<string, any>;
|
overall_score: number;
|
||||||
structure: Record<string, any>;
|
category_scores: Record<string, number>;
|
||||||
readability: Record<string, any>;
|
analysis_summary: BlogSEOAnalysisSummary;
|
||||||
link_suggestions: any[];
|
actionable_recommendations: BlogSEOActionableRecommendation[];
|
||||||
image_alt_status: Record<string, any>;
|
detailed_analysis?: any;
|
||||||
recommendations: string[];
|
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 {
|
export interface BlogSEOMetadataResponse {
|
||||||
@@ -263,6 +301,11 @@ export const blogWriterApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async applySeoRecommendations(payload: BlogSEOApplyRecommendationsRequest): Promise<BlogSEOApplyRecommendationsResponse> {
|
||||||
|
const { data } = await apiClient.post('/api/blog/seo/apply-recommendations', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
// Flow Analysis APIs
|
// Flow Analysis APIs
|
||||||
async analyzeFlowBasic(payload: {
|
async analyzeFlowBasic(payload: {
|
||||||
title: string;
|
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