Merge branch 'recover-stash'
This commit is contained in:
@@ -44,7 +44,7 @@ CORE_ROUTER_REGISTRY = [
|
|||||||
OPTIONAL_ROUTER_REGISTRY = [
|
OPTIONAL_ROUTER_REGISTRY = [
|
||||||
{"name": "blog_writer", "module": "api.blog_writer.router", "attr": "router", "features": {"all", "blog_writer"}},
|
{"name": "blog_writer", "module": "api.blog_writer.router", "attr": "router", "features": {"all", "blog_writer"}},
|
||||||
{"name": "story_writer", "module": "api.story_writer.router", "attr": "router", "features": {"all", "story_writer"}},
|
{"name": "story_writer", "module": "api.story_writer.router", "attr": "router", "features": {"all", "story_writer"}},
|
||||||
{"name": "wix", "module": "api.wix_routes", "attr": "router", "features": {"all"}},
|
{"name": "wix", "module": "api.wix_routes", "attr": "router", "features": {"all", "blog_writer"}},
|
||||||
{"name": "wix_test", "module": "api.wix_routes", "attr": "qa_router", "features": {"all"}},
|
{"name": "wix_test", "module": "api.wix_routes", "attr": "qa_router", "features": {"all"}},
|
||||||
{"name": "blog_seo_analysis", "module": "api.blog_writer.seo_analysis", "attr": "router", "features": {"all", "blog_writer"}},
|
{"name": "blog_seo_analysis", "module": "api.blog_writer.seo_analysis", "attr": "router", "features": {"all", "blog_writer"}},
|
||||||
{"name": "persona", "module": "api.persona_routes", "attr": "router", "features": {"all", "persona"}},
|
{"name": "persona", "module": "api.persona_routes", "attr": "router", "features": {"all", "persona"}},
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ from fastapi import APIRouter, HTTPException, Depends
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
from datetime import datetime
|
||||||
from middleware.auth_middleware import get_current_user
|
from middleware.auth_middleware import get_current_user
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from services.database import get_db as get_db_dependency
|
from services.database import get_db as get_db_dependency
|
||||||
from utils.text_asset_tracker import save_and_track_text_content
|
from utils.text_asset_tracker import save_and_track_text_content
|
||||||
|
from models.content_asset_models import AssetType, AssetSource
|
||||||
|
|
||||||
from models.blog_models import (
|
from models.blog_models import (
|
||||||
BlogResearchRequest,
|
BlogResearchRequest,
|
||||||
@@ -36,6 +38,7 @@ from models.blog_models import (
|
|||||||
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 services.blog_writer.seo.blog_seo_recommendation_applier import BlogSEORecommendationApplier
|
||||||
from services.llm_providers.main_text_generation import llm_text_gen
|
from services.llm_providers.main_text_generation import llm_text_gen
|
||||||
|
from services.content_asset_service import ContentAssetService
|
||||||
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
|
||||||
@@ -1260,3 +1263,233 @@ async def save_complete_blog_asset(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save complete blog asset: {e}")
|
logger.error(f"Failed to save complete blog asset: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------
|
||||||
|
# Blog Asset API (phase-by-phase saving via ContentAsset)
|
||||||
|
# ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class BlogAssetCreateRequest(BaseModel):
|
||||||
|
research_keywords: str = Field(..., max_length=2000, description="Research keywords / topic")
|
||||||
|
topic: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
word_count_target: Optional[int] = Field(default=None, ge=100, le=20000)
|
||||||
|
|
||||||
|
|
||||||
|
class BlogAssetUpdateRequest(BaseModel):
|
||||||
|
phase: Optional[str] = Field(default=None, pattern=r"^(research|outline|content|seo|publish)$")
|
||||||
|
topic: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
selected_title: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
word_count_target: Optional[int] = Field(default=None, ge=100, le=20000)
|
||||||
|
research_data: Optional[Dict[str, Any]] = None
|
||||||
|
outline_data: Optional[Dict[str, Any]] = None
|
||||||
|
content_data: Optional[Dict[str, Any]] = None
|
||||||
|
seo_data: Optional[Dict[str, Any]] = None
|
||||||
|
publish_data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_keywords(kw: str) -> str:
|
||||||
|
"""Normalize keywords for duplicate comparison."""
|
||||||
|
return " ".join(sorted(kw.lower().split()))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/asset", response_model=Dict[str, Any])
|
||||||
|
async def create_blog_asset(
|
||||||
|
request: BlogAssetCreateRequest,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a blog ContentAsset on research start.
|
||||||
|
Returns existing asset if duplicate keywords found (unique topics only).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
user_id = str(current_user.get("id", ""))
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||||
|
|
||||||
|
svc = ContentAssetService(db)
|
||||||
|
normalized_kw = _normalize_keywords(request.research_keywords)
|
||||||
|
|
||||||
|
# Duplicate check — search existing blog assets for matching keywords
|
||||||
|
existing_assets, _ = svc.get_user_assets(
|
||||||
|
user_id=user_id,
|
||||||
|
source_module=AssetSource.BLOG_WRITER,
|
||||||
|
asset_type=AssetType.TEXT,
|
||||||
|
limit=100,
|
||||||
|
)
|
||||||
|
for asset in existing_assets:
|
||||||
|
meta = asset.asset_metadata or {}
|
||||||
|
if meta.get("normalized_keywords") == normalized_kw:
|
||||||
|
logger.info(f"Duplicate blog asset found: {asset.id}, returning existing")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"asset": _asset_to_response(asset),
|
||||||
|
"existing": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create new ContentAsset for this blog
|
||||||
|
title = request.topic or request.research_keywords[:200]
|
||||||
|
asset_metadata = {
|
||||||
|
"phase": "research",
|
||||||
|
"research_keywords": request.research_keywords,
|
||||||
|
"normalized_keywords": normalized_kw,
|
||||||
|
"word_count_target": request.word_count_target,
|
||||||
|
"topic": request.topic,
|
||||||
|
"research_data": None,
|
||||||
|
"outline_data": None,
|
||||||
|
"content_data": None,
|
||||||
|
"seo_data": None,
|
||||||
|
"publish_data": None,
|
||||||
|
}
|
||||||
|
asset = svc.create_asset(
|
||||||
|
user_id=user_id,
|
||||||
|
asset_type=AssetType.TEXT,
|
||||||
|
source_module=AssetSource.BLOG_WRITER,
|
||||||
|
filename=f"blog_{int(datetime.utcnow().timestamp())}.md",
|
||||||
|
file_url=f"/api/blog/content/pending",
|
||||||
|
title=title,
|
||||||
|
description=f"Blog: {title}",
|
||||||
|
tags=["blog", "research"],
|
||||||
|
asset_metadata=asset_metadata,
|
||||||
|
)
|
||||||
|
logger.info(f"✅ Created blog asset: {asset.id}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"asset": _asset_to_response(asset),
|
||||||
|
"existing": False,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create blog asset: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/asset/{asset_id}", response_model=Dict[str, Any])
|
||||||
|
async def update_blog_asset(
|
||||||
|
asset_id: int,
|
||||||
|
request: BlogAssetUpdateRequest,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a blog asset's phase, metadata, and tags."""
|
||||||
|
try:
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
user_id = str(current_user.get("id", ""))
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||||
|
|
||||||
|
svc = ContentAssetService(db)
|
||||||
|
asset = svc.get_asset_by_id(asset_id, user_id)
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(status_code=404, detail="Blog asset not found")
|
||||||
|
|
||||||
|
meta = dict(asset.asset_metadata or {})
|
||||||
|
tags = list(asset.tags or [])
|
||||||
|
|
||||||
|
if request.phase is not None:
|
||||||
|
meta["phase"] = request.phase
|
||||||
|
# Update tags to reflect phase
|
||||||
|
new_tags = [t for t in tags if t not in ("research", "outline", "content", "seo", "publish")]
|
||||||
|
new_tags.append(request.phase)
|
||||||
|
if "blog" not in new_tags:
|
||||||
|
new_tags.append("blog")
|
||||||
|
tags = new_tags
|
||||||
|
|
||||||
|
if request.topic is not None:
|
||||||
|
meta["topic"] = request.topic
|
||||||
|
if request.selected_title is not None:
|
||||||
|
meta["selected_title"] = request.selected_title
|
||||||
|
if request.word_count_target is not None:
|
||||||
|
meta["word_count_target"] = request.word_count_target
|
||||||
|
|
||||||
|
for field in ("research_data", "outline_data", "content_data", "seo_data", "publish_data"):
|
||||||
|
val = getattr(request, field, None)
|
||||||
|
if val is not None:
|
||||||
|
meta[field] = val
|
||||||
|
|
||||||
|
if meta.get("selected_title"):
|
||||||
|
new_title = meta["selected_title"]
|
||||||
|
elif meta.get("topic"):
|
||||||
|
new_title = meta["topic"]
|
||||||
|
else:
|
||||||
|
new_title = asset.title or "Blog Post"
|
||||||
|
|
||||||
|
updated = svc.update_asset(
|
||||||
|
asset_id=asset_id,
|
||||||
|
user_id=user_id,
|
||||||
|
title=new_title[:500],
|
||||||
|
tags=tags,
|
||||||
|
asset_metadata=meta,
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to update asset")
|
||||||
|
|
||||||
|
logger.info(f"✅ Updated blog asset {asset_id}: phase={meta.get('phase')}")
|
||||||
|
return {"success": True, "asset": _asset_to_response(updated)}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update blog asset {asset_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/asset/{asset_id}", response_model=Dict[str, Any])
|
||||||
|
async def get_blog_asset(
|
||||||
|
asset_id: int,
|
||||||
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a blog asset with all phase data."""
|
||||||
|
try:
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
user_id = str(current_user.get("id", ""))
|
||||||
|
if not user_id:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid user ID")
|
||||||
|
|
||||||
|
svc = ContentAssetService(db)
|
||||||
|
asset = svc.get_asset_by_id(asset_id, user_id)
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(status_code=404, detail="Blog asset not found")
|
||||||
|
|
||||||
|
return {"success": True, "asset": _asset_to_response(asset, full=True)}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get blog asset {asset_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_to_response(asset: Any, full: bool = False) -> Dict[str, Any]:
|
||||||
|
"""Convert a ContentAsset to a blog asset response dict."""
|
||||||
|
meta = asset.asset_metadata or {}
|
||||||
|
resp: Dict[str, Any] = {
|
||||||
|
"id": asset.id,
|
||||||
|
"title": asset.title,
|
||||||
|
"description": asset.description,
|
||||||
|
"tags": asset.tags or [],
|
||||||
|
"phase": meta.get("phase", "research"),
|
||||||
|
"research_keywords": meta.get("research_keywords"),
|
||||||
|
"topic": meta.get("topic"),
|
||||||
|
"selected_title": meta.get("selected_title"),
|
||||||
|
"word_count_target": meta.get("word_count_target"),
|
||||||
|
"has_research": meta.get("research_data") is not None,
|
||||||
|
"has_outline": meta.get("outline_data") is not None,
|
||||||
|
"has_content": meta.get("content_data") is not None,
|
||||||
|
"has_seo": meta.get("seo_data") is not None,
|
||||||
|
"has_publish": meta.get("publish_data") is not None,
|
||||||
|
"created_at": asset.created_at.isoformat() if asset.created_at else None,
|
||||||
|
"updated_at": asset.updated_at.isoformat() if asset.updated_at else None,
|
||||||
|
}
|
||||||
|
if full:
|
||||||
|
resp["research_data"] = meta.get("research_data")
|
||||||
|
resp["outline_data"] = meta.get("outline_data")
|
||||||
|
resp["content_data"] = meta.get("content_data")
|
||||||
|
resp["seo_data"] = meta.get("seo_data")
|
||||||
|
resp["publish_data"] = meta.get("publish_data")
|
||||||
|
return resp
|
||||||
|
|||||||
@@ -256,7 +256,8 @@ class TaskManager:
|
|||||||
self.task_storage[task_id]["status"] = "running"
|
self.task_storage[task_id]["status"] = "running"
|
||||||
self.task_storage[task_id]["progress_messages"] = []
|
self.task_storage[task_id]["progress_messages"] = []
|
||||||
|
|
||||||
await self.update_progress(task_id, "📦 Packaging outline and metadata...")
|
await self.update_progress(task_id, "📝 Alwrity is preparing your blog content — this usually takes 20–40 seconds.")
|
||||||
|
await self.update_progress(task_id, "📦 Packaging your outline sections and research data...")
|
||||||
|
|
||||||
# Basic guard: respect global target words
|
# Basic guard: respect global target words
|
||||||
total_target = int(request.globalTargetWords or 1000)
|
total_target = int(request.globalTargetWords or 1000)
|
||||||
@@ -281,16 +282,22 @@ class TaskManager:
|
|||||||
# Check if result came from cache
|
# Check if result came from cache
|
||||||
cache_hit = getattr(result, 'cache_hit', False)
|
cache_hit = getattr(result, 'cache_hit', False)
|
||||||
if cache_hit:
|
if cache_hit:
|
||||||
await self.update_progress(task_id, "⚡ Found cached content - loading instantly!")
|
await self.update_progress(task_id, "⚡ Found existing content in cache — no need to regenerate!")
|
||||||
else:
|
else:
|
||||||
await self.update_progress(task_id, "🤖 Generated fresh content with AI...")
|
await self.update_progress(task_id, "🧠 AI is writing each section with research-backed insights and natural flow...")
|
||||||
await self.update_progress(task_id, "✨ Post-processing and assembling sections...")
|
await self.update_progress(task_id, "✨ Polishing content — improving structure, readability, and transitions...")
|
||||||
|
|
||||||
# Mark completed
|
# Mark completed
|
||||||
self.task_storage[task_id]["status"] = "completed"
|
self.task_storage[task_id]["status"] = "completed"
|
||||||
self.task_storage[task_id]["result"] = result.dict()
|
self.task_storage[task_id]["result"] = result.dict()
|
||||||
await self.update_progress(task_id, f"✅ Generated {len(result.sections)} sections successfully.")
|
section_count = len(result.sections)
|
||||||
|
total_words = sum(getattr(s, 'wordCount', 0) or 0 for s in result.sections)
|
||||||
|
await self.update_progress(
|
||||||
|
task_id,
|
||||||
|
f"✅ Content generation complete! {section_count} sections written ({total_words} words). "
|
||||||
|
"Next up: SEO Analysis to optimize your blog for search engines."
|
||||||
|
)
|
||||||
|
|
||||||
# Note: Blog content tracking is handled in the status endpoint
|
# Note: Blog content tracking is handled in the status endpoint
|
||||||
# to ensure we have proper database session and user context
|
# to ensure we have proper database session and user context
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ async def detect_hallucinations(request: HallucinationDetectionRequest, current_
|
|||||||
text=source.get('text', ''),
|
text=source.get('text', ''),
|
||||||
published_date=source.get('publishedDate'),
|
published_date=source.get('publishedDate'),
|
||||||
author=source.get('author'),
|
author=source.get('author'),
|
||||||
score=source.get('score', 0.5)
|
score=source.get('score') if source.get('score') is not None else 0.5
|
||||||
)
|
)
|
||||||
for source in claim.supporting_sources
|
for source in claim.supporting_sources
|
||||||
]
|
]
|
||||||
@@ -83,7 +83,7 @@ async def detect_hallucinations(request: HallucinationDetectionRequest, current_
|
|||||||
text=source.get('text', ''),
|
text=source.get('text', ''),
|
||||||
published_date=source.get('publishedDate'),
|
published_date=source.get('publishedDate'),
|
||||||
author=source.get('author'),
|
author=source.get('author'),
|
||||||
score=source.get('score', 0.5)
|
score=source.get('score') if source.get('score') is not None else 0.5
|
||||||
)
|
)
|
||||||
for source in claim.refuting_sources
|
for source in claim.refuting_sources
|
||||||
]
|
]
|
||||||
@@ -214,7 +214,7 @@ async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str
|
|||||||
text=source.get('text', ''),
|
text=source.get('text', ''),
|
||||||
published_date=source.get('publishedDate'),
|
published_date=source.get('publishedDate'),
|
||||||
author=source.get('author'),
|
author=source.get('author'),
|
||||||
score=source.get('score', 0.5)
|
score=source.get('score') if source.get('score') is not None else 0.5
|
||||||
)
|
)
|
||||||
for source in claim_result.supporting_sources
|
for source in claim_result.supporting_sources
|
||||||
]
|
]
|
||||||
@@ -226,7 +226,7 @@ async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str
|
|||||||
text=source.get('text', ''),
|
text=source.get('text', ''),
|
||||||
published_date=source.get('publishedDate'),
|
published_date=source.get('publishedDate'),
|
||||||
author=source.get('author'),
|
author=source.get('author'),
|
||||||
score=source.get('score', 0.5)
|
score=source.get('score') if source.get('score') is not None else 0.5
|
||||||
)
|
)
|
||||||
for source in claim_result.refuting_sources
|
for source in claim_result.refuting_sources
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ router = APIRouter(prefix="/api/writing-assistant", tags=["writing-assistant"])
|
|||||||
|
|
||||||
class SuggestRequest(BaseModel):
|
class SuggestRequest(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
cursor_position: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class SourceModel(BaseModel):
|
class SourceModel(BaseModel):
|
||||||
@@ -32,6 +33,7 @@ class SuggestionModel(BaseModel):
|
|||||||
class SuggestResponse(BaseModel):
|
class SuggestResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
suggestions: List[SuggestionModel]
|
suggestions: List[SuggestionModel]
|
||||||
|
message: str = ""
|
||||||
|
|
||||||
|
|
||||||
assistant_service = WritingAssistantService()
|
assistant_service = WritingAssistantService()
|
||||||
@@ -41,9 +43,9 @@ assistant_service = WritingAssistantService()
|
|||||||
async def suggest_endpoint(req: SuggestRequest, current_user: Dict[str, Any] = Depends(get_current_user)) -> SuggestResponse:
|
async def suggest_endpoint(req: SuggestRequest, current_user: Dict[str, Any] = Depends(get_current_user)) -> SuggestResponse:
|
||||||
try:
|
try:
|
||||||
user_id = current_user.get("id")
|
user_id = current_user.get("id")
|
||||||
suggestions = await assistant_service.suggest(req.text, user_id=user_id)
|
suggestions = await assistant_service.suggest(req.text, user_id=user_id, cursor_position=req.cursor_position)
|
||||||
return SuggestResponse(
|
return SuggestResponse(
|
||||||
success=True,
|
success=len(suggestions) > 0,
|
||||||
suggestions=[
|
suggestions=[
|
||||||
SuggestionModel(
|
SuggestionModel(
|
||||||
text=s.text,
|
text=s.text,
|
||||||
|
|||||||
@@ -679,9 +679,6 @@ if _is_full_mode():
|
|||||||
if campaign_creator_router:
|
if campaign_creator_router:
|
||||||
app.include_router(campaign_creator_router)
|
app.include_router(campaign_creator_router)
|
||||||
|
|
||||||
# Include content assets router
|
|
||||||
from api.content_assets.router import router as content_assets_router
|
|
||||||
app.include_router(content_assets_router)
|
|
||||||
router_group_status["platform_extensions"] = {
|
router_group_status["platform_extensions"] = {
|
||||||
"mounted": True,
|
"mounted": True,
|
||||||
"reason": "Full mode",
|
"reason": "Full mode",
|
||||||
@@ -692,6 +689,10 @@ else:
|
|||||||
"reason": "Skipped in feature-only mode",
|
"reason": "Skipped in feature-only mode",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Include content assets router (always — core utility, not feature-specific)
|
||||||
|
from api.content_assets.router import router as content_assets_router
|
||||||
|
app.include_router(content_assets_router)
|
||||||
|
|
||||||
# Include Podcast Maker router (only when podcast feature is enabled)
|
# Include Podcast Maker router (only when podcast feature is enabled)
|
||||||
if _is_feature_enabled("podcast") and "all" not in get_enabled_features():
|
if _is_feature_enabled("podcast") and "all" not in get_enabled_features():
|
||||||
from api.podcast.router import router as podcast_router
|
from api.podcast.router import router as podcast_router
|
||||||
|
|||||||
@@ -76,12 +76,22 @@ async def handle_gsc_callback(
|
|||||||
|
|
||||||
success = gsc_service.handle_oauth_callback(code, state)
|
success = gsc_service.handle_oauth_callback(code, state)
|
||||||
|
|
||||||
|
# If state verification failed, check if user is already connected
|
||||||
|
# (handles duplicate callbacks where state was consumed by a prior request)
|
||||||
|
if not success:
|
||||||
|
user_id_from_state = state.split(':')[0] if ':' in state else None
|
||||||
|
if user_id_from_state:
|
||||||
|
existing_creds = gsc_service.load_user_credentials(user_id_from_state)
|
||||||
|
if existing_creds:
|
||||||
|
logger.info(f"GSC OAuth state already consumed, but user {user_id_from_state} has valid credentials — treating as success")
|
||||||
|
success = True
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
logger.info("GSC OAuth callback handled successfully")
|
logger.info("GSC OAuth callback handled successfully")
|
||||||
|
|
||||||
# Create GSC insights task immediately after successful connection
|
# Create GSC insights task immediately after successful connection
|
||||||
try:
|
try:
|
||||||
from services.database import SessionLocal
|
from services.database import get_session_for_user
|
||||||
from services.platform_insights_monitoring_service import create_platform_insights_task
|
from services.platform_insights_monitoring_service import create_platform_insights_task
|
||||||
|
|
||||||
# Get user_id from state (stored during OAuth flow)
|
# Get user_id from state (stored during OAuth flow)
|
||||||
@@ -89,23 +99,24 @@ async def handle_gsc_callback(
|
|||||||
user_id = state.split(':')[0] if ':' in state else None
|
user_id = state.split(':')[0] if ':' in state else None
|
||||||
|
|
||||||
if user_id:
|
if user_id:
|
||||||
db = SessionLocal()
|
db = get_session_for_user(user_id)
|
||||||
try:
|
if db:
|
||||||
# Create insights task without site_url to avoid API calls
|
try:
|
||||||
# The executor will fetch it when the task runs (weekly)
|
task_result = create_platform_insights_task(
|
||||||
task_result = create_platform_insights_task(
|
user_id=user_id,
|
||||||
user_id=user_id,
|
platform='gsc',
|
||||||
platform='gsc',
|
site_url=None,
|
||||||
site_url=None, # Will be fetched by executor when task runs
|
db=db
|
||||||
db=db
|
)
|
||||||
)
|
|
||||||
|
if task_result.get('success'):
|
||||||
if task_result.get('success'):
|
logger.info(f"Created GSC insights task for user {user_id}")
|
||||||
logger.info(f"Created GSC insights task for user {user_id}")
|
else:
|
||||||
else:
|
logger.warning(f"Failed to create GSC insights task: {task_result.get('error')}")
|
||||||
logger.warning(f"Failed to create GSC insights task: {task_result.get('error')}")
|
finally:
|
||||||
finally:
|
db.close()
|
||||||
db.close()
|
else:
|
||||||
|
logger.warning(f"Could not create DB session for user {user_id}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Could not extract user_id from state: {state}")
|
logger.warning(f"Could not extract user_id from state: {state}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -125,7 +136,10 @@ async def handle_gsc_callback(
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
return HTMLResponse(content=html)
|
return HTMLResponse(
|
||||||
|
content=html,
|
||||||
|
headers={"Cross-Origin-Opener-Policy": "unsafe-none"},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error("Failed to handle GSC OAuth callback")
|
logger.error("Failed to handle GSC OAuth callback")
|
||||||
html = """
|
html = """
|
||||||
@@ -140,7 +154,11 @@ async def handle_gsc_callback(
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
return HTMLResponse(status_code=400, content=html)
|
return HTMLResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=html,
|
||||||
|
headers={"Cross-Origin-Opener-Policy": "unsafe-none"},
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling GSC OAuth callback: {e}")
|
logger.error(f"Error handling GSC OAuth callback: {e}")
|
||||||
@@ -157,7 +175,11 @@ async def handle_gsc_callback(
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
return HTMLResponse(status_code=500, content=html)
|
return HTMLResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=html,
|
||||||
|
headers={"Cross-Origin-Opener-Policy": "unsafe-none"},
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/sites")
|
@router.get("/sites")
|
||||||
async def get_gsc_sites(user: dict = Depends(get_current_user)):
|
async def get_gsc_sites(user: dict = Depends(get_current_user)):
|
||||||
|
|||||||
@@ -122,9 +122,6 @@ class MediumBlogGenerator:
|
|||||||
payload = {
|
payload = {
|
||||||
"title": req.title,
|
"title": req.title,
|
||||||
"globalTargetWords": req.globalTargetWords or 1000,
|
"globalTargetWords": req.globalTargetWords or 1000,
|
||||||
"persona": req.persona.dict() if req.persona else None,
|
|
||||||
"tone": req.tone,
|
|
||||||
"audience": req.audience,
|
|
||||||
"sections": [section_block(s) for s in req.sections],
|
"sections": [section_block(s) for s in req.sections],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +133,6 @@ class MediumBlogGenerator:
|
|||||||
- Industry: {req.persona.industry or 'General'}
|
- Industry: {req.persona.industry or 'General'}
|
||||||
- Tone: {req.persona.tone or 'Professional'}
|
- Tone: {req.persona.tone or 'Professional'}
|
||||||
- Audience: {req.persona.audience or 'General readers'}
|
- Audience: {req.persona.audience or 'General readers'}
|
||||||
- Persona ID: {req.persona.persona_id or 'Default'}
|
|
||||||
|
|
||||||
Write content that reflects this persona's expertise and communication style.
|
Write content that reflects this persona's expertise and communication style.
|
||||||
Use industry-specific terminology and examples where appropriate.
|
Use industry-specific terminology and examples where appropriate.
|
||||||
@@ -154,40 +150,19 @@ class MediumBlogGenerator:
|
|||||||
"Return ONLY valid JSON with no markdown formatting or explanations."
|
"Return ONLY valid JSON with no markdown formatting or explanations."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build persona-specific content instructions
|
|
||||||
persona_instructions = ""
|
|
||||||
if req.persona:
|
|
||||||
industry = req.persona.industry or 'General'
|
|
||||||
tone = req.persona.tone or 'Professional'
|
|
||||||
audience = req.persona.audience or 'General readers'
|
|
||||||
|
|
||||||
persona_instructions = f"""
|
|
||||||
PERSONA-DRIVEN CONTENT REQUIREMENTS:
|
|
||||||
- Write as an expert in {industry} industry
|
|
||||||
- Use {tone} tone appropriate for {audience}
|
|
||||||
- Include industry-specific examples and terminology
|
|
||||||
- Demonstrate authority and expertise in the field
|
|
||||||
- Use language that resonates with {audience}
|
|
||||||
- Maintain consistent voice that reflects this persona's expertise
|
|
||||||
"""
|
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Write blog content for the following sections. Each section should be {req.globalTargetWords or 1000} words total, distributed across all sections.\n\n"
|
f"Write blog content for the following sections. Total target: {req.globalTargetWords or 1000} words, distributed across all sections.\n\n"
|
||||||
f"Blog Title: {req.title}\n\n"
|
f"Blog Title: {req.title}\n\n"
|
||||||
"For each section, write engaging content that:\n"
|
"For each section, write engaging content that:\n"
|
||||||
"- Follows the key points provided\n"
|
"- Follows the key points provided\n"
|
||||||
"- Uses the suggested keywords naturally\n"
|
"- Uses the suggested keywords naturally\n"
|
||||||
"- Meets the target word count\n"
|
"- Meets the target word count\n"
|
||||||
"- Maintains professional tone\n"
|
|
||||||
"- References the provided sources when relevant\n"
|
|
||||||
"- Breaks content into clear paragraphs (2-4 sentences each)\n"
|
"- Breaks content into clear paragraphs (2-4 sentences each)\n"
|
||||||
"- Uses double line breaks (\\n\\n) between paragraphs for proper formatting\n"
|
"- Uses double line breaks (\\n\\n) between paragraphs\n"
|
||||||
"- Starts with an engaging opening paragraph\n"
|
"- Starts with an engaging opening paragraph\n"
|
||||||
"- Ends with a strong concluding paragraph\n"
|
"- Ends with a strong concluding paragraph\n\n"
|
||||||
f"{persona_instructions}\n"
|
"Return a JSON object with 'title' and 'sections' array. Each section must have 'id', 'heading', 'content', 'wordCount', and 'sources'.\n\n"
|
||||||
"IMPORTANT: Format the 'content' field with proper paragraph breaks using \\n\\n between paragraphs.\n\n"
|
f"Sections:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
||||||
"Return a JSON object with 'title' and 'sections' array. Each section should have 'id', 'heading', 'content', and 'wordCount'.\n\n"
|
|
||||||
f"Sections to write:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -195,7 +170,9 @@ class MediumBlogGenerator:
|
|||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
json_struct=schema,
|
json_struct=schema,
|
||||||
system_prompt=system,
|
system_prompt=system,
|
||||||
user_id=user_id
|
user_id=user_id,
|
||||||
|
max_tokens=None,
|
||||||
|
temperature=0.3,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
# Re-raise HTTPExceptions (e.g., 429 subscription limit) to preserve error details
|
# Re-raise HTTPExceptions (e.g., 429 subscription limit) to preserve error details
|
||||||
|
|||||||
@@ -322,7 +322,7 @@ class ExaResearchProvider(BaseProvider):
|
|||||||
'text': getattr(result, 'text', ''),
|
'text': getattr(result, 'text', ''),
|
||||||
'publishedDate': getattr(result, 'publishedDate', ''),
|
'publishedDate': getattr(result, 'publishedDate', ''),
|
||||||
'author': getattr(result, 'author', ''),
|
'author': getattr(result, 'author', ''),
|
||||||
'score': getattr(result, 'score', 0.5),
|
'score': (lambda v: v if v is not None else 0.5)(getattr(result, 'score', 0.5)),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Track usage
|
# Track usage
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from models.product_marketing_models import Campaign, CampaignProposal, Campaign
|
|||||||
from models.product_asset_models import ProductAsset, ProductStyleTemplate, EcommerceExport
|
from models.product_asset_models import ProductAsset, ProductStyleTemplate, EcommerceExport
|
||||||
# Podcast Maker models use SubscriptionBase, but import to ensure models are registered
|
# Podcast Maker models use SubscriptionBase, but import to ensure models are registered
|
||||||
from models.podcast_models import PodcastProject
|
from models.podcast_models import PodcastProject
|
||||||
|
|
||||||
# Research models use SubscriptionBase
|
# Research models use SubscriptionBase
|
||||||
from models.research_models import ResearchProject
|
from models.research_models import ResearchProject
|
||||||
# Video Studio models
|
# Video Studio models
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
GSC Brainstorm Service for ALwrity.
|
GSC Brainstorm Service for ALwrity.
|
||||||
|
|
||||||
Analyzes Google Search Console data to suggest blog topics the user should write about.
|
Analyzes Google Search Console data to suggest blog topics the user should write about.
|
||||||
Combines rule-based heuristics (high-impression/low-CTR keywords, near-page-1 positions)
|
Combines rule-based heuristics with LLM-powered strategic recommendations tailored to
|
||||||
with LLM-powered strategic recommendations tailored to the user's topic intent.
|
the user's topic intent. Designed for non-SEO-experts: every insight includes plain-English
|
||||||
|
explanations of WHY it matters and WHAT to do about it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -21,9 +22,10 @@ class GSCBrainstormService:
|
|||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. Fetch real GSC search analytics (query + page data, 30 days)
|
1. Fetch real GSC search analytics (query + page data, 30 days)
|
||||||
2. Apply rule-based filters (Content Optimization, Content Enhancement, Keyword Gap)
|
2. Compute derived metrics (CTR benchmarks, estimated traffic uplift, content formats)
|
||||||
3. Generate LLM-powered strategic recommendations contextualised to the user's keywords
|
3. Apply rule-based filters (Quick Wins, Optimization, Enhancement, Rising Stars, Page Issues)
|
||||||
4. Return structured results
|
4. Generate LLM-powered strategic recommendations contextualised to the user's keywords
|
||||||
|
5. Return structured results with all data exposed for rich frontend display
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, gsc_service: GSCService = None):
|
def __init__(self, gsc_service: GSCService = None):
|
||||||
@@ -39,18 +41,8 @@ class GSCBrainstormService:
|
|||||||
keywords: str,
|
keywords: str,
|
||||||
site_url: Optional[str] = None,
|
site_url: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
|
||||||
Generate blog topic suggestions from the user's GSC data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: Clerk user ID (must have GSC connected).
|
|
||||||
keywords: User's 3+ word topic intent (e.g. "content marketing strategy").
|
|
||||||
site_url: Optional site URL; auto-selected from user's first GSC site if omitted.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with content_opportunities, keyword_gaps, ai_recommendations, summary.
|
|
||||||
"""
|
|
||||||
self._user_id = user_id
|
self._user_id = user_id
|
||||||
|
|
||||||
# 1. Resolve site_url
|
# 1. Resolve site_url
|
||||||
if not site_url:
|
if not site_url:
|
||||||
sites = self.gsc_service.get_site_list(user_id)
|
sites = self.gsc_service.get_site_list(user_id)
|
||||||
@@ -59,6 +51,8 @@ class GSCBrainstormService:
|
|||||||
"error": "No GSC sites found. Make sure your site is verified in Google Search Console.",
|
"error": "No GSC sites found. Make sure your site is verified in Google Search Console.",
|
||||||
"content_opportunities": [],
|
"content_opportunities": [],
|
||||||
"keyword_gaps": [],
|
"keyword_gaps": [],
|
||||||
|
"quick_wins": [],
|
||||||
|
"page_opportunities": [],
|
||||||
"ai_recommendations": {},
|
"ai_recommendations": {},
|
||||||
"summary": {},
|
"summary": {},
|
||||||
}
|
}
|
||||||
@@ -80,6 +74,8 @@ class GSCBrainstormService:
|
|||||||
"error": analytics.get("error", "Failed to fetch GSC data"),
|
"error": analytics.get("error", "Failed to fetch GSC data"),
|
||||||
"content_opportunities": [],
|
"content_opportunities": [],
|
||||||
"keyword_gaps": [],
|
"keyword_gaps": [],
|
||||||
|
"quick_wins": [],
|
||||||
|
"page_opportunities": [],
|
||||||
"ai_recommendations": {},
|
"ai_recommendations": {},
|
||||||
"summary": {},
|
"summary": {},
|
||||||
}
|
}
|
||||||
@@ -93,9 +89,11 @@ class GSCBrainstormService:
|
|||||||
|
|
||||||
if not keywords_data:
|
if not keywords_data:
|
||||||
return {
|
return {
|
||||||
"error": "No keyword data available for the selected period.",
|
"error": "No keyword data available for the selected period. This usually means your site is new to GSC or hasn't received search traffic yet.",
|
||||||
"content_opportunities": [],
|
"content_opportunities": [],
|
||||||
"keyword_gaps": [],
|
"keyword_gaps": [],
|
||||||
|
"quick_wins": [],
|
||||||
|
"page_opportunities": [],
|
||||||
"ai_recommendations": {},
|
"ai_recommendations": {},
|
||||||
"summary": {
|
"summary": {
|
||||||
"site_url": site_url,
|
"site_url": site_url,
|
||||||
@@ -107,18 +105,23 @@ class GSCBrainstormService:
|
|||||||
# 4. Rule-based analysis
|
# 4. Rule-based analysis
|
||||||
content_opportunities = self._identify_content_opportunities(keywords_data)
|
content_opportunities = self._identify_content_opportunities(keywords_data)
|
||||||
keyword_gaps = self._identify_keyword_gaps(keywords_data)
|
keyword_gaps = self._identify_keyword_gaps(keywords_data)
|
||||||
|
quick_wins = self._identify_quick_wins(keywords_data)
|
||||||
|
page_opportunities = self._identify_page_opportunities(pages_data)
|
||||||
|
|
||||||
# 5. Summary metrics
|
# 5. Summary metrics
|
||||||
summary = self._compute_summary(keywords_data, pages_data, site_url, start_date, end_date)
|
summary = self._compute_summary(keywords_data, pages_data, site_url, start_date, end_date)
|
||||||
|
|
||||||
# 6. AI recommendations (best-effort; don't fail the whole request on LLM error)
|
# 6. AI recommendations
|
||||||
ai_recommendations = self._generate_ai_recommendations(
|
ai_recommendations = self._generate_ai_recommendations(
|
||||||
keywords_data, pages_data, summary, keywords
|
keywords_data, pages_data, summary, keywords,
|
||||||
|
content_opportunities, quick_wins, keyword_gaps,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"content_opportunities": content_opportunities,
|
"content_opportunities": content_opportunities,
|
||||||
"keyword_gaps": keyword_gaps,
|
"keyword_gaps": keyword_gaps,
|
||||||
|
"quick_wins": quick_wins,
|
||||||
|
"page_opportunities": page_opportunities,
|
||||||
"ai_recommendations": ai_recommendations,
|
"ai_recommendations": ai_recommendations,
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
}
|
}
|
||||||
@@ -168,39 +171,53 @@ class GSCBrainstormService:
|
|||||||
opportunities: List[Dict[str, Any]] = []
|
opportunities: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
# Rule 1: Content Optimization — high impressions, low CTR
|
# Rule 1: Content Optimization — high impressions, low CTR
|
||||||
|
# Meaning: Google is SHOWING your page for this query but people aren't clicking.
|
||||||
|
# The content probably ranks but title/meta/snippet isn't compelling enough.
|
||||||
for kw in keywords_data:
|
for kw in keywords_data:
|
||||||
if kw["impressions"] > 500 and kw["ctr"] < 3:
|
if kw["impressions"] > 500 and kw["ctr"] < 3:
|
||||||
|
estimated_gain = int(kw["impressions"] * 0.05) - kw["clicks"]
|
||||||
opportunities.append({
|
opportunities.append({
|
||||||
"type": "Content Optimization",
|
"type": "Content Optimization",
|
||||||
"keyword": kw["keyword"],
|
"keyword": kw["keyword"],
|
||||||
"opportunity": (
|
"opportunity": (
|
||||||
f"Optimize existing content for '{kw['keyword']}' "
|
f"Your site appears for '{kw['keyword']}' ({kw['impressions']:,} times/month) "
|
||||||
f"to improve CTR from {kw['ctr']:.1f}% "
|
f"but only {kw['ctr']:.1f}% click. Improving your title and meta description "
|
||||||
f"(position {kw['position']:.1f})"
|
f"could bring ~{max(estimated_gain, 5)} more clicks/month."
|
||||||
),
|
),
|
||||||
"potential_impact": "High",
|
"potential_impact": "High" if kw["impressions"] > 1000 else "Medium",
|
||||||
"current_position": kw["position"],
|
"current_position": kw["position"],
|
||||||
|
"current_ctr": kw["ctr"],
|
||||||
"impressions": kw["impressions"],
|
"impressions": kw["impressions"],
|
||||||
|
"clicks": kw["clicks"],
|
||||||
|
"estimated_traffic_gain": max(estimated_gain, 5),
|
||||||
"priority": "High" if kw["impressions"] > 1000 else "Medium",
|
"priority": "High" if kw["impressions"] > 1000 else "Medium",
|
||||||
|
"suggested_format": GSCBrainstormService._suggest_format(kw["keyword"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Rule 2: Content Enhancement — positions 11-20 with decent impressions
|
# Rule 2: Content Enhancement — positions 11-20 with decent impressions
|
||||||
|
# Meaning: You're on page 2 of Google. A small content boost could push you to page 1,
|
||||||
|
# where CTR increases dramatically (page 1 gets ~95% of all clicks).
|
||||||
for kw in keywords_data:
|
for kw in keywords_data:
|
||||||
if 10 < kw["position"] <= 20 and kw["impressions"] > 100:
|
if 10 < kw["position"] <= 20 and kw["impressions"] > 100:
|
||||||
|
estimated_gain = int(kw["impressions"] * 0.08)
|
||||||
opportunities.append({
|
opportunities.append({
|
||||||
"type": "Content Enhancement",
|
"type": "Content Enhancement",
|
||||||
"keyword": kw["keyword"],
|
"keyword": kw["keyword"],
|
||||||
"opportunity": (
|
"opportunity": (
|
||||||
f"Enhance content for '{kw['keyword']}' to move from "
|
f"'{kw['keyword']}' ranks #{kw['position']:.0f} (page 2). "
|
||||||
f"position {kw['position']:.1f} to the first page"
|
f"Moving to page 1 could capture ~{estimated_gain} more clicks/month "
|
||||||
|
f"from {kw['impressions']:,} impressions."
|
||||||
),
|
),
|
||||||
"potential_impact": "Medium",
|
"potential_impact": "High" if kw["impressions"] > 500 else "Medium",
|
||||||
"current_position": kw["position"],
|
"current_position": kw["position"],
|
||||||
|
"current_ctr": kw["ctr"],
|
||||||
"impressions": kw["impressions"],
|
"impressions": kw["impressions"],
|
||||||
"priority": "Medium",
|
"clicks": kw["clicks"],
|
||||||
|
"estimated_traffic_gain": estimated_gain,
|
||||||
|
"priority": "High" if kw["impressions"] > 500 else "Medium",
|
||||||
|
"suggested_format": GSCBrainstormService._suggest_format(kw["keyword"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by impressions descending, keep top 10
|
|
||||||
opportunities.sort(key=lambda x: x["impressions"], reverse=True)
|
opportunities.sort(key=lambda x: x["impressions"], reverse=True)
|
||||||
return opportunities[:10]
|
return opportunities[:10]
|
||||||
|
|
||||||
@@ -212,15 +229,111 @@ class GSCBrainstormService:
|
|||||||
|
|
||||||
for kw in keywords_data:
|
for kw in keywords_data:
|
||||||
if 4 <= kw["position"] <= 20 and kw["impressions"] >= 50:
|
if 4 <= kw["position"] <= 20 and kw["impressions"] >= 50:
|
||||||
|
# Estimate traffic gain if this keyword moved to position 1-3
|
||||||
|
# Position 1 avg CTR ~31%, position 3 ~11%, current position CTR estimate
|
||||||
|
position_1_ctr = 31.0
|
||||||
|
current_ctr = kw["ctr"]
|
||||||
|
estimated_gain = max(int(kw["impressions"] * (position_1_ctr - current_ctr) / 100), 1)
|
||||||
|
|
||||||
gaps.append({
|
gaps.append({
|
||||||
"keyword": kw["keyword"],
|
"keyword": kw["keyword"],
|
||||||
"position": kw["position"],
|
"position": kw["position"],
|
||||||
"impressions": kw["impressions"],
|
"impressions": kw["impressions"],
|
||||||
|
"current_ctr": kw["ctr"],
|
||||||
|
"clicks": kw["clicks"],
|
||||||
|
"estimated_traffic_if_page1": estimated_gain,
|
||||||
|
"gap_from_page1": round(kw["position"] - 3, 1),
|
||||||
})
|
})
|
||||||
|
|
||||||
gaps.sort(key=lambda x: x["impressions"], reverse=True)
|
gaps.sort(key=lambda x: x["impressions"], reverse=True)
|
||||||
return gaps[:10]
|
return gaps[:10]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _identify_quick_wins(
|
||||||
|
keywords_data: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Keywords already on page 1 (positions 4-10) that could reach top 3
|
||||||
|
with minor improvements — the highest-ROI opportunities."""
|
||||||
|
quick_wins: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for kw in keywords_data:
|
||||||
|
if 4 <= kw["position"] <= 10 and kw["impressions"] >= 100:
|
||||||
|
# Position 3 CTR ≈ 11%, position 5 CTR ≈ 6%
|
||||||
|
# Small improvements can yield big traffic gains
|
||||||
|
target_ctr = 11.0 # approximate CTR for position 3
|
||||||
|
estimated_gain = max(int(kw["impressions"] * (target_ctr - kw["ctr"]) / 100), 1)
|
||||||
|
|
||||||
|
quick_wins.append({
|
||||||
|
"keyword": kw["keyword"],
|
||||||
|
"position": kw["position"],
|
||||||
|
"impressions": kw["impressions"],
|
||||||
|
"current_ctr": kw["ctr"],
|
||||||
|
"clicks": kw["clicks"],
|
||||||
|
"estimated_traffic_gain": estimated_gain,
|
||||||
|
"reason": (
|
||||||
|
f"Already on page 1 at position #{kw['position']:.0f}. "
|
||||||
|
f"Optimizing this page could increase CTR from {kw['ctr']:.1f}% "
|
||||||
|
f"to ~{target_ctr:.0f}%, gaining ~{estimated_gain} clicks/month."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
quick_wins.sort(key=lambda x: x["estimated_traffic_gain"], reverse=True)
|
||||||
|
return quick_wins[:5]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _identify_page_opportunities(
|
||||||
|
pages_data: List[Dict[str, Any]],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Pages with high impressions but low CTR — the content or meta needs work."""
|
||||||
|
opportunities: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for pg in pages_data:
|
||||||
|
if pg["impressions"] > 300 and pg["ctr"] < 2.0:
|
||||||
|
short_page = pg["page"].rstrip("/").rsplit("/", 1)[-1].replace("-", " ").title()
|
||||||
|
if len(short_page) > 60:
|
||||||
|
short_page = short_page[:57] + "..."
|
||||||
|
opportunities.append({
|
||||||
|
"page": pg["page"],
|
||||||
|
"page_title": short_page,
|
||||||
|
"impressions": pg["impressions"],
|
||||||
|
"clicks": pg["clicks"],
|
||||||
|
"current_ctr": pg["ctr"],
|
||||||
|
"current_position": pg["position"],
|
||||||
|
"reason": (
|
||||||
|
f"This page gets {pg['impressions']:,} impressions but only {pg['ctr']:.1f}% CTR. "
|
||||||
|
f"Reviewing the title and meta description could significantly boost clicks."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
opportunities.sort(key=lambda x: x["impressions"], reverse=True)
|
||||||
|
return opportunities[:5]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Content format suggestion
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _suggest_format(keyword: str) -> str:
|
||||||
|
"""Suggest a content format based on keyword patterns."""
|
||||||
|
kw = keyword.lower()
|
||||||
|
if any(w in kw for w in ["how to", "how do", "guide", "tutorial", "steps"]):
|
||||||
|
return "How-To Guide"
|
||||||
|
if any(w in kw for w in ["vs", "versus", "compare", "comparison", "difference"]):
|
||||||
|
return "Comparison"
|
||||||
|
if any(w in kw for w in ["best", "top", "recommended", "review", "reviews"]):
|
||||||
|
return "Top Picks / Review"
|
||||||
|
if any(w in kw for w in ["what is", "definition", "meaning", "explained"]):
|
||||||
|
return "Explainer"
|
||||||
|
if any(w in kw for w in ["list", "examples", "ideas", "tips", "ways"]):
|
||||||
|
return "Listicle"
|
||||||
|
if any(w in kw for w in ["free", "cheap", "alternative", "budget"]):
|
||||||
|
return "Budget / Alternative"
|
||||||
|
if any(w in kw for w in ["template", "calculator", "tool", "checker"]):
|
||||||
|
return "Tool / Template"
|
||||||
|
if any(w in kw for w in ["2024", "2025", "2026", "trends", "prediction", "future"]):
|
||||||
|
return "Trend Report"
|
||||||
|
return "In-Depth Article"
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Summary metrics
|
# Summary metrics
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -248,6 +361,16 @@ class GSCBrainstormService:
|
|||||||
top_keywords = sorted(keywords_data, key=lambda x: x["impressions"], reverse=True)[:5]
|
top_keywords = sorted(keywords_data, key=lambda x: x["impressions"], reverse=True)[:5]
|
||||||
top_pages = sorted(pages_data, key=lambda x: x["clicks"], reverse=True)[:3]
|
top_pages = sorted(pages_data, key=lambda x: x["clicks"], reverse=True)[:3]
|
||||||
|
|
||||||
|
# Health score: 0-100 based on how many keywords are on page 1
|
||||||
|
total_kw = len(keywords_data) or 1
|
||||||
|
page1_pct = (pos_1_3 + pos_4_10) / total_kw * 100
|
||||||
|
top3_pct = pos_1_3 / total_kw * 100
|
||||||
|
health_score = round(min(top3_pct * 3 + page1_pct * 0.7, 100), 0)
|
||||||
|
|
||||||
|
# CTR benchmark: industry average is ~3.1% for position 1-10
|
||||||
|
ctr_benchmark = 3.1
|
||||||
|
ctr_vs_benchmark = round(avg_ctr - ctr_benchmark, 2)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"site_url": site_url,
|
"site_url": site_url,
|
||||||
"date_range": {"start": start_date, "end": end_date},
|
"date_range": {"start": start_date, "end": end_date},
|
||||||
@@ -256,6 +379,8 @@ class GSCBrainstormService:
|
|||||||
"total_clicks": total_clicks,
|
"total_clicks": total_clicks,
|
||||||
"avg_ctr": avg_ctr,
|
"avg_ctr": avg_ctr,
|
||||||
"avg_position": avg_position,
|
"avg_position": avg_position,
|
||||||
|
"ctr_vs_benchmark": ctr_vs_benchmark,
|
||||||
|
"health_score": health_score,
|
||||||
"keyword_distribution": {
|
"keyword_distribution": {
|
||||||
"positions_1_3": pos_1_3,
|
"positions_1_3": pos_1_3,
|
||||||
"positions_4_10": pos_4_10,
|
"positions_4_10": pos_4_10,
|
||||||
@@ -263,11 +388,22 @@ class GSCBrainstormService:
|
|||||||
"positions_21_plus": pos_21_plus,
|
"positions_21_plus": pos_21_plus,
|
||||||
},
|
},
|
||||||
"top_keywords": [
|
"top_keywords": [
|
||||||
{"keyword": kw["keyword"], "impressions": kw["impressions"], "position": kw["position"]}
|
{
|
||||||
|
"keyword": kw["keyword"],
|
||||||
|
"impressions": kw["impressions"],
|
||||||
|
"clicks": kw["clicks"],
|
||||||
|
"position": kw["position"],
|
||||||
|
"ctr": kw["ctr"],
|
||||||
|
}
|
||||||
for kw in top_keywords
|
for kw in top_keywords
|
||||||
],
|
],
|
||||||
"top_pages": [
|
"top_pages": [
|
||||||
{"page": pg["page"], "clicks": pg["clicks"], "impressions": pg["impressions"]}
|
{
|
||||||
|
"page": pg["page"],
|
||||||
|
"clicks": pg["clicks"],
|
||||||
|
"impressions": pg["impressions"],
|
||||||
|
"ctr": pg["ctr"],
|
||||||
|
}
|
||||||
for pg in top_pages
|
for pg in top_pages
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -282,60 +418,110 @@ class GSCBrainstormService:
|
|||||||
pages_data: List[Dict],
|
pages_data: List[Dict],
|
||||||
summary: Dict,
|
summary: Dict,
|
||||||
user_keywords: str,
|
user_keywords: str,
|
||||||
|
content_opportunities: List[Dict],
|
||||||
|
quick_wins: List[Dict],
|
||||||
|
keyword_gaps: List[Dict],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
top_kw = ", ".join(kw["keyword"] for kw in summary.get("top_keywords", []))
|
top_kw_list = summary.get("top_keywords", [])
|
||||||
|
top_kw_str = "\n".join(
|
||||||
|
f" • {kw['keyword']}: {kw['impressions']:,} impressions, position {kw['position']}, {kw['ctr']:.1f}% CTR"
|
||||||
|
for kw in top_kw_list[:10]
|
||||||
|
)
|
||||||
dist = summary.get("keyword_distribution", {})
|
dist = summary.get("keyword_distribution", {})
|
||||||
|
|
||||||
prompt = f"""Analyze this Google Search Console data and suggest blog topics the user should write about.
|
opp_str = ""
|
||||||
|
if content_opportunities:
|
||||||
|
opp_str = "\nCONTENT OPPORTUNITIES (rule-based findings):\n" + "\n".join(
|
||||||
|
f" • {o['keyword']}: {o['opportunity']}"
|
||||||
|
for o in content_opportunities[:5]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
opp_str = "\nNo major content opportunities detected from rule-based analysis."
|
||||||
|
|
||||||
USER'S TOPIC INTENT: "{user_keywords}"
|
qw_str = ""
|
||||||
|
if quick_wins:
|
||||||
|
qw_str = "\nQUICK WINS (already on page 1, easy to optimize):\n" + "\n".join(
|
||||||
|
f" • {q['keyword']}: position #{q['position']:.0f}, {q['current_ctr']:.1f}% CTR, est. +{q['estimated_traffic_gain']} clicks/month"
|
||||||
|
for q in quick_wins[:3]
|
||||||
|
)
|
||||||
|
|
||||||
SEARCH PERFORMANCE SUMMARY:
|
prompt = f"""You are an expert SEO content strategist analyzing real Google Search Console data for a blog writer.
|
||||||
- Total Keywords Tracked: {summary.get('total_keywords_analyzed', 0)}
|
|
||||||
|
The user wants to write about: "{user_keywords}"
|
||||||
|
|
||||||
|
Here is their GSC data for the last 30 days:
|
||||||
|
|
||||||
|
PERFORMANCE OVERVIEW:
|
||||||
|
- Total Keywords: {summary.get('total_keywords_analyzed', 0)}
|
||||||
- Total Impressions: {summary.get('total_impressions', 0):,}
|
- Total Impressions: {summary.get('total_impressions', 0):,}
|
||||||
- Total Clicks: {summary.get('total_clicks', 0):,}
|
- Total Clicks: {summary.get('total_clicks', 0):,}
|
||||||
- Average CTR: {summary.get('avg_ctr', 0):.2f}%
|
- Average CTR: {summary.get('avg_ctr', 0):.2f}% (industry avg for positions 1-10 is ~3.1%)
|
||||||
- Average Position: {summary.get('avg_position', 0):.1f}
|
- Average Position: {summary.get('avg_position', 0):.1f}
|
||||||
|
- SEO Health Score: {summary.get('health_score', 0)}/100
|
||||||
|
|
||||||
TOP PERFORMING KEYWORDS:
|
TOP KEYWORDS BY IMPRESSIONS:
|
||||||
{top_kw}
|
{top_kw_str}
|
||||||
|
|
||||||
KEYWORD POSITION DISTRIBUTION:
|
KEYWORD POSITION DISTRIBUTION:
|
||||||
- Positions 1-3: {dist.get('positions_1_3', 0)}
|
- Position 1-3 (top results): {dist.get('positions_1_3', 0)} keywords
|
||||||
- Positions 4-10: {dist.get('positions_4_10', 0)}
|
- Position 4-10 (page 1): {dist.get('positions_4_10', 0)} keywords
|
||||||
- Positions 11-20: {dist.get('positions_11_20', 0)}
|
- Position 11-20 (page 2): {dist.get('positions_11_20', 0)} keywords
|
||||||
- Positions 21+: {dist.get('positions_21_plus', 0)}
|
- Position 21+ (page 3+): {dist.get('positions_21_plus', 0)} keywords
|
||||||
|
{opp_str}
|
||||||
|
{qw_str}
|
||||||
|
|
||||||
Based on this data, provide:
|
Based on this data, provide EXACT blog post suggestions the user should write.
|
||||||
|
|
||||||
1. IMMEDIATE TOPIC OPPORTUNITIES (0-30 days):
|
For each suggestion include:
|
||||||
- Specific blog post titles the user should write
|
1. A specific, compelling blog post TITLE (not vague topic)
|
||||||
- Each tied to a keyword opportunity from the data
|
2. The keyword it targets and why (based on the data above)
|
||||||
- 3-5 suggestions
|
3. The recommended content format (how-to, listicle, comparison, etc.)
|
||||||
|
4. Estimated impact (how many more clicks/month they could gain)
|
||||||
|
|
||||||
2. CONTENT STRATEGY TOPICS (1-3 months):
|
Return your response in this EXACT JSON format (no markdown, no code fences):
|
||||||
- New topic clusters to build authority
|
|
||||||
- Content pillar ideas
|
|
||||||
- 3-5 suggestions
|
|
||||||
|
|
||||||
3. LONG-TERM CONTENT VISION (3-12 months):
|
|
||||||
- Market expansion topics
|
|
||||||
- Authority-building content ideas
|
|
||||||
- 3-5 suggestions
|
|
||||||
|
|
||||||
IMPORTANT: Relate every topic suggestion to the user's interest in "{user_keywords}".
|
|
||||||
Return your response in this exact JSON format:
|
|
||||||
{{
|
{{
|
||||||
"immediate_opportunities": ["topic 1", "topic 2", "topic 3"],
|
"immediate_opportunities": [
|
||||||
"content_strategy": ["strategy 1", "strategy 2", "strategy 3"],
|
{{
|
||||||
"long_term_strategy": ["vision 1", "vision 2", "vision 3"]
|
"title": "Specific Blog Post Title Here",
|
||||||
}}"""
|
"keyword": "target keyword",
|
||||||
|
"reason": "Why this will work based on the data",
|
||||||
|
"format": "How-To Guide | Listicle | Comparison | Explainer | etc.",
|
||||||
|
"estimated_impact": "Estimated X more clicks/month"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"content_strategy": [
|
||||||
|
{{
|
||||||
|
"title": "Pillar Content Title",
|
||||||
|
"keyword": "target keyword",
|
||||||
|
"reason": "Strategic reasoning",
|
||||||
|
"format": "Content format",
|
||||||
|
"estimated_impact": "Expected impact"
|
||||||
|
}}
|
||||||
|
],
|
||||||
|
"long_term_strategy": [
|
||||||
|
{{
|
||||||
|
"title": "Authority Building Title",
|
||||||
|
"keyword": "target keyword",
|
||||||
|
"reason": "Long-term reasoning",
|
||||||
|
"format": "Content format",
|
||||||
|
"estimated_impact": "Expected long-term impact"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Provide 3-5 items in each category
|
||||||
|
- Every suggestion MUST relate to the user's interest in "{user_keywords}"
|
||||||
|
- Titles should be specific and compelling, like real blog post headlines
|
||||||
|
- Use the data above to justify each recommendation
|
||||||
|
- Prioritize keywords with high impressions but low CTR or low position"""
|
||||||
|
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
"You are an enterprise SEO content strategist. Provide specific, data-driven "
|
"You are an expert SEO content strategist. You analyze Google Search Console data "
|
||||||
"blog topic suggestions that will improve the user's search performance. "
|
"and provide specific, actionable blog post recommendations that will drive real traffic. "
|
||||||
"Always respond with valid JSON matching the requested format."
|
"You always respond with valid JSON matching the requested format. "
|
||||||
|
"Every recommendation must be backed by the data provided."
|
||||||
)
|
)
|
||||||
|
|
||||||
result = llm_text_gen(
|
result = llm_text_gen(
|
||||||
@@ -350,27 +536,58 @@ Return your response in this exact JSON format:
|
|||||||
if parsed:
|
if parsed:
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
return self._fallback_ai_recommendations(keywords_data)
|
return self._fallback_ai_recommendations(keywords_data, content_opportunities, quick_wins)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"GSC brainstorm AI recommendations failed: {e}")
|
logger.warning(f"GSC brainstorm AI recommendations failed: {e}")
|
||||||
return self._fallback_ai_recommendations(keywords_data)
|
return self._fallback_ai_recommendations(keywords_data, content_opportunities, quick_wins)
|
||||||
|
|
||||||
@staticmethod
|
def _parse_ai_response(self, raw: str) -> Optional[Dict[str, Any]]:
|
||||||
def _parse_ai_response(raw: str) -> Optional[Dict[str, List[str]]]:
|
|
||||||
try:
|
try:
|
||||||
json_start = raw.find("{")
|
# Strip markdown code fences if present
|
||||||
json_end = raw.rfind("}") + 1
|
cleaned = raw.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
first_newline = cleaned.find("\n")
|
||||||
|
if first_newline != -1:
|
||||||
|
cleaned = cleaned[first_newline + 1:]
|
||||||
|
if cleaned.endswith("```"):
|
||||||
|
cleaned = cleaned[:-3].strip()
|
||||||
|
|
||||||
|
json_start = cleaned.find("{")
|
||||||
|
json_end = cleaned.rfind("}") + 1
|
||||||
if json_start == -1 or json_end == 0:
|
if json_start == -1 or json_end == 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
chunk = raw[json_start:json_end]
|
chunk = cleaned[json_start:json_end]
|
||||||
parsed = json.loads(chunk)
|
parsed = json.loads(chunk)
|
||||||
|
|
||||||
|
def normalize_section(section: Any) -> List[Dict[str, str]]:
|
||||||
|
if not isinstance(section, list):
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for item in section:
|
||||||
|
if isinstance(item, str):
|
||||||
|
result.append({
|
||||||
|
"title": item.split(":")[0].strip() if ":" in item else item[:60],
|
||||||
|
"keyword": "",
|
||||||
|
"reason": item,
|
||||||
|
"format": "",
|
||||||
|
"estimated_impact": "",
|
||||||
|
})
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
result.append({
|
||||||
|
"title": str(item.get("title", "")),
|
||||||
|
"keyword": str(item.get("keyword", "")),
|
||||||
|
"reason": str(item.get("reason", "")),
|
||||||
|
"format": str(item.get("format", "")),
|
||||||
|
"estimated_impact": str(item.get("estimated_impact", "")),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"immediate_opportunities": parsed.get("immediate_opportunities", [])[:5],
|
"immediate_opportunities": normalize_section(parsed.get("immediate_opportunities", []))[:5],
|
||||||
"content_strategy": parsed.get("content_strategy", [])[:5],
|
"content_strategy": normalize_section(parsed.get("content_strategy", []))[:5],
|
||||||
"long_term_strategy": parsed.get("long_term_strategy", [])[:5],
|
"long_term_strategy": normalize_section(parsed.get("long_term_strategy", []))[:5],
|
||||||
}
|
}
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
logger.warning(f"Failed to parse AI brainstorm response as JSON: {e}")
|
logger.warning(f"Failed to parse AI brainstorm response as JSON: {e}")
|
||||||
@@ -379,26 +596,53 @@ Return your response in this exact JSON format:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _fallback_ai_recommendations(
|
def _fallback_ai_recommendations(
|
||||||
keywords_data: List[Dict],
|
keywords_data: List[Dict],
|
||||||
|
content_opportunities: List[Dict],
|
||||||
|
quick_wins: List[Dict],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
top_kw = keywords_data[:3] if keywords_data else []
|
top_kw = keywords_data[:3] if keywords_data else []
|
||||||
immediate = []
|
immediate = []
|
||||||
for kw in top_kw:
|
|
||||||
immediate.append(
|
# Build from quick wins first (highest ROI)
|
||||||
f"Write a comprehensive guide on '{kw['keyword']}' "
|
for qw in quick_wins[:2]:
|
||||||
f"(currently at position {kw['position']:.1f} with "
|
immediate.append({
|
||||||
f"{kw['impressions']} impressions)"
|
"title": f"How to Rank #{int(qw['position'])} for '{qw['keyword']}' — Optimization Guide",
|
||||||
)
|
"keyword": qw["keyword"],
|
||||||
|
"reason": qw.get("reason", f"Already on page 1 at position {qw['position']:.0f}"),
|
||||||
|
"format": "How-To Guide",
|
||||||
|
"estimated_impact": f"+{qw.get('estimated_traffic_gain', 10)} clicks/month",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Then from content opportunities
|
||||||
|
for opp in content_opportunities[:2]:
|
||||||
|
immediate.append({
|
||||||
|
"title": f"Complete Guide to {opp['keyword'].title()}",
|
||||||
|
"keyword": opp["keyword"],
|
||||||
|
"reason": opp.get("opportunity", f"{opp['impressions']:,} impressions with room to improve"),
|
||||||
|
"format": opp.get("suggested_format", "In-Depth Article"),
|
||||||
|
"estimated_impact": f"+{opp.get('estimated_traffic_gain', 10)} clicks/month",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Fill remaining with top keywords
|
||||||
|
remaining = 5 - len(immediate)
|
||||||
|
for kw in top_kw[:remaining]:
|
||||||
|
immediate.append({
|
||||||
|
"title": f"The Ultimate Guide to {kw['keyword'].title()}",
|
||||||
|
"keyword": kw["keyword"],
|
||||||
|
"reason": f"Top keyword with {kw['impressions']:,} impressions (position {kw['position']:.1f})",
|
||||||
|
"format": "In-Depth Article",
|
||||||
|
"estimated_impact": f"+{max(int(kw['impressions'] * 0.03), 5)} clicks/month",
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"immediate_opportunities": immediate or ["No keyword data available for recommendations"],
|
"immediate_opportunities": immediate or [{"title": "No keyword data available", "keyword": "", "reason": "Connect GSC to get personalized suggestions", "format": "", "estimated_impact": ""}],
|
||||||
"content_strategy": [
|
"content_strategy": [
|
||||||
"Develop topic clusters around your top-performing keywords",
|
{"title": "Topic Cluster: Build Authority Around Your Core Topics", "keyword": "", "reason": "Clustered content ranks higher and captures more long-tail queries", "format": "Pillar Page + Spokes", "estimated_impact": "+50-200 clicks/month over 3 months"},
|
||||||
"Create comparison and vs-style content for competitive terms",
|
{"title": "Comparison Guide: Your Product vs. Alternatives", "keyword": "", "reason": "Comparison content captures high-intent searchers ready to decide", "format": "Comparison", "estimated_impact": "+20-80 clicks/month"},
|
||||||
"Build FAQ sections targeting question-based queries",
|
{"title": "FAQ: Answer What Your Audience Is Asking", "keyword": "", "reason": "FAQs capture featured snippets and voice search queries", "format": "FAQ / Listicle", "estimated_impact": "+30-100 clicks/month"},
|
||||||
],
|
],
|
||||||
"long_term_strategy": [
|
"long_term_strategy": [
|
||||||
"Build domain authority through pillar content",
|
{"title": "Pillar Content: The Definitive Resource in Your Niche", "keyword": "", "reason": "Comprehensive guides become authoritative references that attract backlinks", "format": "Long-Form Guide", "estimated_impact": "+100-500 clicks/month over 6-12 months"},
|
||||||
"Expand into adjacent topic areas",
|
{"title": "Trend Report: What's Next in Your Industry", "keyword": "", "reason": "Forward-looking content captures emerging search demand early", "format": "Trend Report", "estimated_impact": "+50-200 clicks/month"},
|
||||||
"Develop thought leadership content series",
|
{"title": "Thought Leadership: Expert Roundup and Insights", "keyword": "", "reason": "Expert content builds E-E-A-T signals that improve overall domain authority", "format": "Expert Roundup", "estimated_impact": "+30-100 clicks/month per piece"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -250,10 +250,10 @@ class GSCService:
|
|||||||
flow = Flow.from_client_config(
|
flow = Flow.from_client_config(
|
||||||
self.client_config,
|
self.client_config,
|
||||||
scopes=self.scopes,
|
scopes=self.scopes,
|
||||||
redirect_uri=redirect_uri
|
redirect_uri=redirect_uri,
|
||||||
|
autogenerate_code_verifier=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use a custom state that includes user_id for routing the callback to the correct DB
|
|
||||||
random_state = secrets.token_urlsafe(32)
|
random_state = secrets.token_urlsafe(32)
|
||||||
state = f"{user_id}:{random_state}"
|
state = f"{user_id}:{random_state}"
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ class GSCService:
|
|||||||
logger.error(f"User database not found for user {user_id}")
|
logger.error(f"User database not found for user {user_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Verify state in user's DB
|
# Verify state in user's DB (but don't delete yet — delete after successful token exchange)
|
||||||
with sqlite3.connect(db_path) as conn:
|
with sqlite3.connect(db_path) as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute('SELECT user_id FROM gsc_oauth_states WHERE state = ?', (state,))
|
cursor.execute('SELECT user_id FROM gsc_oauth_states WHERE state = ?', (state,))
|
||||||
@@ -309,10 +309,6 @@ class GSCService:
|
|||||||
if not result:
|
if not result:
|
||||||
logger.error(f"Invalid or expired GSC OAuth state for user {user_id}")
|
logger.error(f"Invalid or expired GSC OAuth state for user {user_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Clean up state
|
|
||||||
cursor.execute('DELETE FROM gsc_oauth_states WHERE state = ?', (state,))
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# Exchange code for credentials
|
# Exchange code for credentials
|
||||||
if not self.client_config:
|
if not self.client_config:
|
||||||
@@ -322,12 +318,22 @@ class GSCService:
|
|||||||
flow = Flow.from_client_config(
|
flow = Flow.from_client_config(
|
||||||
self.client_config,
|
self.client_config,
|
||||||
scopes=self.scopes,
|
scopes=self.scopes,
|
||||||
redirect_uri=os.getenv('GSC_REDIRECT_URI', 'http://localhost:8000/gsc/callback')
|
redirect_uri=os.getenv('GSC_REDIRECT_URI', 'http://localhost:8000/gsc/callback'),
|
||||||
|
autogenerate_code_verifier=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
flow.fetch_token(code=authorization_code)
|
flow.fetch_token(code=authorization_code)
|
||||||
credentials = flow.credentials
|
credentials = flow.credentials
|
||||||
|
|
||||||
|
# State consumed successfully — clean up
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('DELETE FROM gsc_oauth_states WHERE state = ?', (state,))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as cleanup_err:
|
||||||
|
logger.warning(f"Failed to clean up OAuth state: {cleanup_err}")
|
||||||
|
|
||||||
# Save credentials
|
# Save credentials
|
||||||
return self.save_user_credentials(user_id, credentials)
|
return self.save_user_credentials(user_id, credentials)
|
||||||
|
|
||||||
|
|||||||
@@ -343,18 +343,28 @@ class HallucinationDetector:
|
|||||||
logger.error(f"Error in batch evidence search: {str(e)}")
|
logger.error(f"Error in batch evidence search: {str(e)}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _map_source_refs_from_reasoning(self, reasoning: str, sources: List[Dict[str, Any]]) -> List[int]:
|
||||||
|
"""Parse 'Source N' references from reasoning text and return 0-based indices."""
|
||||||
|
import re
|
||||||
|
indices = set()
|
||||||
|
for match in re.finditer(r'Source\s+(\d+)', reasoning):
|
||||||
|
ref = int(match.group(1))
|
||||||
|
if 1 <= ref <= len(sources):
|
||||||
|
indices.add(ref - 1) # convert 1-based → 0-based
|
||||||
|
return sorted(indices)
|
||||||
|
|
||||||
async def _assess_claims_batch(self, claims: List[str], sources: List[Dict[str, Any]], user_id: str = None) -> List[Claim]:
|
async def _assess_claims_batch(self, claims: List[str], sources: List[Dict[str, Any]], user_id: str = None) -> List[Claim]:
|
||||||
"""Assess multiple claims against sources in one LLM call."""
|
"""Assess multiple claims against sources in one LLM call."""
|
||||||
try:
|
try:
|
||||||
claims_to_assess = claims[:3]
|
claims_to_assess = claims[:3]
|
||||||
|
|
||||||
combined_sources = "\n\n".join([
|
combined_sources = "\n\n".join([
|
||||||
f"Source {i+1}: {src.get('url','')}\nText: {src.get('text','')[:1000]}"
|
f"Source [{i}]: {src.get('url','')}\nText: {src.get('text','')[:1000]}"
|
||||||
for i, src in enumerate(sources)
|
for i, src in enumerate(sources)
|
||||||
])
|
])
|
||||||
|
|
||||||
claims_text = "\n".join([
|
claims_text = "\n".join([
|
||||||
f"Claim {i+1}: {claim}"
|
f"Claim {i}: {claim}"
|
||||||
for i, claim in enumerate(claims_to_assess)
|
for i, claim in enumerate(claims_to_assess)
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -367,12 +377,14 @@ class HallucinationDetector:
|
|||||||
' "claim_index": 0,\n'
|
' "claim_index": 0,\n'
|
||||||
' "assessment": "supported" or "refuted" or "insufficient_information",\n'
|
' "assessment": "supported" or "refuted" or "insufficient_information",\n'
|
||||||
' "confidence": number between 0.0 and 1.0,\n'
|
' "confidence": number between 0.0 and 1.0,\n'
|
||||||
' "supporting_sources": [array of source indices that support the claim],\n'
|
' "supporting_sources": [array of 0-based source indices, e.g. [0, 2] for Source [0] and Source [2]],\n'
|
||||||
' "refuting_sources": [array of source indices that refute the claim],\n'
|
' "refuting_sources": [array of 0-based source indices, e.g. [1] for Source [1]],\n'
|
||||||
' "reasoning": "brief explanation of your assessment"\n'
|
' "reasoning": "brief explanation of your assessment"\n'
|
||||||
' }\n'
|
' }\n'
|
||||||
' ]\n'
|
' ]\n'
|
||||||
"}\n\n"
|
"}\n\n"
|
||||||
|
"IMPORTANT: Source indices are 0-based. Source [0] is the first source, Source [1] is the second, etc.\n"
|
||||||
|
"For every 'supported' or 'refuted' claim you MUST include the relevant source indices.\n\n"
|
||||||
f"Claims to verify:\n{claims_text}\n\n"
|
f"Claims to verify:\n{claims_text}\n\n"
|
||||||
f"Sources:\n{combined_sources}\n\n"
|
f"Sources:\n{combined_sources}\n\n"
|
||||||
"Return only the JSON object:"
|
"Return only the JSON object:"
|
||||||
@@ -407,6 +419,15 @@ class HallucinationDetector:
|
|||||||
if isinstance(idx, int) and 0 <= idx < len(sources):
|
if isinstance(idx, int) and 0 <= idx < len(sources):
|
||||||
refuting_sources.append(sources[idx])
|
refuting_sources.append(sources[idx])
|
||||||
|
|
||||||
|
# Fallback: parse "Source N" from reasoning text when LLM omits indices
|
||||||
|
if not supporting_sources and not refuting_sources and sources and assessment.get('reasoning'):
|
||||||
|
ref_indices = self._map_source_refs_from_reasoning(assessment.get('reasoning', ''), sources)
|
||||||
|
if ref_indices:
|
||||||
|
if assessment.get('assessment') == 'supported':
|
||||||
|
supporting_sources = [sources[i] for i in ref_indices]
|
||||||
|
elif assessment.get('assessment') == 'refuted':
|
||||||
|
refuting_sources = [sources[i] for i in ref_indices]
|
||||||
|
|
||||||
verified_claims.append(Claim(
|
verified_claims.append(Claim(
|
||||||
text=claim,
|
text=claim,
|
||||||
confidence=float(assessment.get('confidence', 0.5)),
|
confidence=float(assessment.get('confidence', 0.5)),
|
||||||
@@ -464,7 +485,7 @@ class HallucinationDetector:
|
|||||||
"""Assess whether sources support or refute the claim using LLM."""
|
"""Assess whether sources support or refute the claim using LLM."""
|
||||||
try:
|
try:
|
||||||
combined_sources = "\n\n".join([
|
combined_sources = "\n\n".join([
|
||||||
f"Source {i+1}: {src.get('url','')}\nText: {src.get('text','')[:2000]}"
|
f"Source [{i}]: {src.get('url','')}\nText: {src.get('text','')[:2000]}"
|
||||||
for i, src in enumerate(sources)
|
for i, src in enumerate(sources)
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -474,10 +495,12 @@ class HallucinationDetector:
|
|||||||
"{\n"
|
"{\n"
|
||||||
' "assessment": "supported" or "refuted" or "insufficient_information",\n'
|
' "assessment": "supported" or "refuted" or "insufficient_information",\n'
|
||||||
' "confidence": number between 0.0 and 1.0,\n'
|
' "confidence": number between 0.0 and 1.0,\n'
|
||||||
' "supporting_sources": [array of source indices that support the claim],\n'
|
' "supporting_sources": [array of 0-based source indices, e.g. [0, 2] for Source [0] and Source [2]],\n'
|
||||||
' "refuting_sources": [array of source indices that refute the claim],\n'
|
' "refuting_sources": [array of 0-based source indices, e.g. [1] for Source [1]],\n'
|
||||||
' "reasoning": "brief explanation of your assessment"\n'
|
' "reasoning": "brief explanation of your assessment"\n'
|
||||||
"}\n\n"
|
"}\n\n"
|
||||||
|
"IMPORTANT: Source indices are 0-based. Source [0] is the first source, Source [1] is the second, etc.\n"
|
||||||
|
"For 'supported' or 'refuted' you MUST include the relevant source indices.\n\n"
|
||||||
f"Claim to verify: {claim}\n\n"
|
f"Claim to verify: {claim}\n\n"
|
||||||
f"Sources:\n{combined_sources}\n\n"
|
f"Sources:\n{combined_sources}\n\n"
|
||||||
"Return only the JSON object:"
|
"Return only the JSON object:"
|
||||||
@@ -508,6 +531,15 @@ class HallucinationDetector:
|
|||||||
if isinstance(idx, int) and 0 <= idx < len(sources):
|
if isinstance(idx, int) and 0 <= idx < len(sources):
|
||||||
refuting_sources.append(sources[idx])
|
refuting_sources.append(sources[idx])
|
||||||
|
|
||||||
|
# Fallback: parse "Source N" from reasoning text when LLM omits indices
|
||||||
|
if not supporting_sources and not refuting_sources and sources and result.get('reasoning'):
|
||||||
|
ref_indices = self._map_source_refs_from_reasoning(result.get('reasoning', ''), sources)
|
||||||
|
if ref_indices:
|
||||||
|
if result.get('assessment') == 'supported':
|
||||||
|
supporting_sources = [sources[i] for i in ref_indices]
|
||||||
|
elif result.get('assessment') == 'refuted':
|
||||||
|
refuting_sources = [sources[i] for i in ref_indices]
|
||||||
|
|
||||||
# Validate assessment value
|
# Validate assessment value
|
||||||
valid_assessments = ['supported', 'refuted', 'insufficient_information']
|
valid_assessments = ['supported', 'refuted', 'insufficient_information']
|
||||||
if result['assessment'] not in valid_assessments:
|
if result['assessment'] not in valid_assessments:
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def llm_text_gen(
|
|||||||
preferred_provider: Optional[str] = None,
|
preferred_provider: Optional[str] = None,
|
||||||
flow_type: Optional[str] = None,
|
flow_type: Optional[str] = None,
|
||||||
max_tokens: Optional[int] = None,
|
max_tokens: Optional[int] = None,
|
||||||
|
temperature: Optional[float] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate text using Language Model (LLM) based on the provided prompt.
|
Generate text using Language Model (LLM) based on the provided prompt.
|
||||||
@@ -58,6 +59,8 @@ def llm_text_gen(
|
|||||||
preferred_hf_models (list, optional): Preferred HuggingFace models.
|
preferred_hf_models (list, optional): Preferred HuggingFace models.
|
||||||
preferred_provider (str, optional): Preferred provider (google, huggingface).
|
preferred_provider (str, optional): Preferred provider (google, huggingface).
|
||||||
flow_type (str, optional): Flow type for logging (e.g., 'sif_agent', 'premium_tool').
|
flow_type (str, optional): Flow type for logging (e.g., 'sif_agent', 'premium_tool').
|
||||||
|
max_tokens (int, optional): Max tokens for response. If None, provider default is used.
|
||||||
|
temperature (float, optional): Temperature for generation (0.0-1.0). If None, defaults to 0.7.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Generated text based on the prompt.
|
str: Generated text based on the prompt.
|
||||||
@@ -75,9 +78,8 @@ def llm_text_gen(
|
|||||||
# Set default values for LLM parameters
|
# Set default values for LLM parameters
|
||||||
gpt_provider = "google" # Default to Google Gemini
|
gpt_provider = "google" # Default to Google Gemini
|
||||||
model = "gemini-2.0-flash-001"
|
model = "gemini-2.0-flash-001"
|
||||||
temperature = 0.7
|
if temperature is None:
|
||||||
if max_tokens is None:
|
temperature = 0.7
|
||||||
max_tokens = 4000
|
|
||||||
top_p = 0.9
|
top_p = 0.9
|
||||||
n = 1
|
n = 1
|
||||||
fp = 16
|
fp = 16
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
import random
|
import random
|
||||||
@@ -17,42 +18,33 @@ class WritingSuggestion:
|
|||||||
|
|
||||||
class WritingAssistantService:
|
class WritingAssistantService:
|
||||||
"""
|
"""
|
||||||
Minimal writing assistant that combines Exa search with Gemini continuation.
|
Writing assistant that combines Exa search with LLM continuation.
|
||||||
- Exa provides relevant sources with content snippets
|
- Searches relevant sources using the content near the cursor position
|
||||||
- Gemini generates a short, cited continuation based on current text and sources
|
- Generates a short continuation grounded in sources
|
||||||
|
- Confidence derived from source availability and quality
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
# COST CONTROL: Daily usage limits
|
|
||||||
self.daily_api_calls = 0
|
self.daily_api_calls = 0
|
||||||
self.daily_limit = 50 # Max 50 API calls per day (~$2.50 max cost)
|
self.daily_limit = 50
|
||||||
self.last_reset_date = None
|
self.last_reset_date = None
|
||||||
|
|
||||||
def _get_cached_suggestion(self, text: str) -> WritingSuggestion | None:
|
def _get_cached_suggestion(self, text: str) -> WritingSuggestion | None:
|
||||||
"""No cached suggestions - always use real API calls for authentic results."""
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _check_daily_limit(self) -> bool:
|
def _check_daily_limit(self) -> bool:
|
||||||
"""Check if we're within daily API usage limits."""
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
today = datetime.date.today()
|
today = datetime.date.today()
|
||||||
|
|
||||||
# Reset counter if it's a new day
|
|
||||||
if self.last_reset_date != today:
|
if self.last_reset_date != today:
|
||||||
self.daily_api_calls = 0
|
self.daily_api_calls = 0
|
||||||
self.last_reset_date = today
|
self.last_reset_date = today
|
||||||
|
|
||||||
# Check if we've exceeded the limit
|
|
||||||
if self.daily_api_calls >= self.daily_limit:
|
if self.daily_api_calls >= self.daily_limit:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Increment counter for this API call
|
|
||||||
self.daily_api_calls += 1
|
self.daily_api_calls += 1
|
||||||
logger.info(f"Writing assistant API call #{self.daily_api_calls}/{self.daily_limit} today")
|
logger.info(f"Writing assistant API call #{self.daily_api_calls}/{self.daily_limit} today")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def suggest(self, text: str, user_id: str | None = None) -> List[WritingSuggestion]:
|
async def suggest(self, text: str, user_id: str | None = None, cursor_position: Optional[int] = None) -> List[WritingSuggestion]:
|
||||||
if not text or len(text.strip()) < 6:
|
if not text or len(text.strip()) < 6:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -67,26 +59,41 @@ class WritingAssistantService:
|
|||||||
if len(text.strip()) < 50:
|
if len(text.strip()) < 50:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# 1) Find relevant sources via Exa
|
# Use text before cursor for context (where the user is actively writing)
|
||||||
sources = await self._search_sources(text, user_id=user_id)
|
if cursor_position is not None and 0 < cursor_position <= len(text):
|
||||||
|
context_text = text[:cursor_position]
|
||||||
|
else:
|
||||||
|
context_text = text
|
||||||
|
|
||||||
# 2) Generate continuation suggestion via LLM grounded in sources
|
# 1) Find relevant sources via Exa (non-fatal)
|
||||||
suggestion_text, confidence = await self._generate_continuation(text, sources, user_id=user_id)
|
sources = []
|
||||||
|
try:
|
||||||
|
sources = await self._search_sources(context_text, user_id=user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"WritingAssistant Exa search failed, proceeding without sources: {e}")
|
||||||
|
|
||||||
|
# 2) Generate continuation suggestion via LLM
|
||||||
|
suggestion_text, confidence = await self._generate_continuation(context_text, sources, user_id=user_id)
|
||||||
|
|
||||||
if not suggestion_text:
|
if not suggestion_text:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return [WritingSuggestion(text=suggestion_text.strip(), confidence=confidence, sources=sources)]
|
return [WritingSuggestion(text=suggestion_text.strip(), confidence=confidence, sources=sources)]
|
||||||
|
|
||||||
async def _search_sources(self, text: str, user_id: str = None) -> List[Dict[str, Any]]:
|
async def _search_sources(self, context_text: str, user_id: str = None) -> List[Dict[str, Any]]:
|
||||||
"""Search for relevant sources using ExaResearchProvider with subscription checks."""
|
"""Search Exa using the last sentence before cursor for a focused query."""
|
||||||
try:
|
try:
|
||||||
from services.blog_writer.research.exa_provider import ExaResearchProvider
|
from services.blog_writer.research.exa_provider import ExaResearchProvider
|
||||||
|
|
||||||
exa_query = (
|
# Extract the last sentence from context to use as a focused search query
|
||||||
(text[-1000:] if len(text) > 1000 else text)
|
sentences = re.split(r'(?<=[.!?])\s+', context_text.strip())
|
||||||
+ "\n\nIf you found the above interesting, here's another useful resource to read:"
|
last_sentence = sentences[-1].strip().strip('"').strip("'") if sentences else context_text
|
||||||
)
|
|
||||||
|
# If very short, use last two sentences
|
||||||
|
if len(last_sentence) < 20 and len(sentences) >= 2:
|
||||||
|
last_sentence = ' '.join(s[-2:]).strip().strip('"').strip("'")
|
||||||
|
|
||||||
|
exa_query = last_sentence[:500] if len(last_sentence) > 500 else last_sentence
|
||||||
|
|
||||||
provider = ExaResearchProvider()
|
provider = ExaResearchProvider()
|
||||||
sources = await provider.simple_search(
|
sources = await provider.simple_search(
|
||||||
@@ -95,7 +102,6 @@ class WritingAssistantService:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalize keys to match expected format
|
|
||||||
normalized = []
|
normalized = []
|
||||||
for s in sources:
|
for s in sources:
|
||||||
normalized.append({
|
normalized.append({
|
||||||
@@ -104,7 +110,7 @@ class WritingAssistantService:
|
|||||||
"text": s.get("text", ""),
|
"text": s.get("text", ""),
|
||||||
"author": s.get("author", ""),
|
"author": s.get("author", ""),
|
||||||
"published_date": s.get("publishedDate", ""),
|
"published_date": s.get("publishedDate", ""),
|
||||||
"score": float(s.get("score", 0.5)),
|
"score": float(s.get("score") if s.get("score") is not None else 0.5),
|
||||||
})
|
})
|
||||||
|
|
||||||
if not normalized:
|
if not normalized:
|
||||||
@@ -151,8 +157,21 @@ class WritingAssistantService:
|
|||||||
suggestion = (str(ai_resp or "")).strip()
|
suggestion = (str(ai_resp or "")).strip()
|
||||||
if not suggestion:
|
if not suggestion:
|
||||||
raise Exception("Assistive writer returned empty suggestion")
|
raise Exception("Assistive writer returned empty suggestion")
|
||||||
confidence = 0.7
|
|
||||||
return suggestion, confidence
|
# Dynamic confidence based on source quality and response signals
|
||||||
|
confidence = 0.5
|
||||||
|
if sources:
|
||||||
|
# More sources and higher scores = more confident
|
||||||
|
avg_score = sum(s.get("score", 0.5) for s in sources) / len(sources)
|
||||||
|
confidence = 0.5 + (len(sources) / 6.0) * 0.3 + avg_score * 0.2
|
||||||
|
if suggestion.endswith(('.', '!', '?')):
|
||||||
|
confidence += 0.05
|
||||||
|
# Check if citation hint was included
|
||||||
|
if '[http' in suggestion or '((' in suggestion:
|
||||||
|
confidence += 0.05
|
||||||
|
confidence = min(confidence, 1.0)
|
||||||
|
|
||||||
|
return suggestion, round(confidence, 2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WritingAssistant _generate_continuation error: {e}")
|
logger.error(f"WritingAssistant _generate_continuation error: {e}")
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -73,22 +73,14 @@ export const getApiUrl = () => {
|
|||||||
throw new Error('REACT_APP_API_URL environment variable is required for production. Please set it in your Vercel project settings.');
|
throw new Error('REACT_APP_API_URL environment variable is required for production. Please set it in your Vercel project settings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isProduction) {
|
// Always respect REACT_APP_API_URL if explicitly set — behavior is independent of
|
||||||
|
// whether the browser is on localhost, ngrok, or any other hostname.
|
||||||
|
if (apiUrl) {
|
||||||
return apiUrl;
|
return apiUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In development, use localhost by default
|
// Development fallback when no env var is configured
|
||||||
const envUrl = process.env.REACT_APP_API_URL;
|
return 'http://localhost:8000';
|
||||||
const isLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost';
|
|
||||||
const isNgrok = envUrl && envUrl.includes('ngrok');
|
|
||||||
if (isLocalhost) {
|
|
||||||
if (isNgrok) {
|
|
||||||
console.warn('[apiClient] ⚠️ Overriding ngrok API URL in dev; using http://localhost:8000 to avoid CORS.');
|
|
||||||
}
|
|
||||||
return 'http://localhost:8000';
|
|
||||||
}
|
|
||||||
// Non-localhost dev (rare): use env if provided, otherwise localhost
|
|
||||||
return envUrl || 'http://localhost:8000';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a shared axios instance for all API calls
|
// Create a shared axios instance for all API calls
|
||||||
|
|||||||
@@ -6,20 +6,56 @@ export interface ContentOpportunity {
|
|||||||
opportunity: string;
|
opportunity: string;
|
||||||
potential_impact: 'High' | 'Medium';
|
potential_impact: 'High' | 'Medium';
|
||||||
current_position: number;
|
current_position: number;
|
||||||
|
current_ctr: number;
|
||||||
impressions: number;
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
estimated_traffic_gain: number;
|
||||||
priority: 'High' | 'Medium';
|
priority: 'High' | 'Medium';
|
||||||
|
suggested_format: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeywordGap {
|
export interface KeywordGap {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
position: number;
|
position: number;
|
||||||
impressions: number;
|
impressions: number;
|
||||||
|
current_ctr: number;
|
||||||
|
clicks: number;
|
||||||
|
estimated_traffic_if_page1: number;
|
||||||
|
gap_from_page1: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickWin {
|
||||||
|
keyword: string;
|
||||||
|
position: number;
|
||||||
|
impressions: number;
|
||||||
|
current_ctr: number;
|
||||||
|
clicks: number;
|
||||||
|
estimated_traffic_gain: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageOpportunity {
|
||||||
|
page: string;
|
||||||
|
page_title: string;
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
current_ctr: number;
|
||||||
|
current_position: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIRecommendation {
|
||||||
|
title: string;
|
||||||
|
keyword: string;
|
||||||
|
reason: string;
|
||||||
|
format: string;
|
||||||
|
estimated_impact: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AIRecommendations {
|
export interface AIRecommendations {
|
||||||
immediate_opportunities: string[];
|
immediate_opportunities: AIRecommendation[];
|
||||||
content_strategy: string[];
|
content_strategy: AIRecommendation[];
|
||||||
long_term_strategy: string[];
|
long_term_strategy: AIRecommendation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrainstormSummary {
|
export interface BrainstormSummary {
|
||||||
@@ -30,20 +66,24 @@ export interface BrainstormSummary {
|
|||||||
total_clicks: number;
|
total_clicks: number;
|
||||||
avg_ctr: number;
|
avg_ctr: number;
|
||||||
avg_position: number;
|
avg_position: number;
|
||||||
|
ctr_vs_benchmark: number;
|
||||||
|
health_score: number;
|
||||||
keyword_distribution: {
|
keyword_distribution: {
|
||||||
positions_1_3: number;
|
positions_1_3: number;
|
||||||
positions_4_10: number;
|
positions_4_10: number;
|
||||||
positions_11_20: number;
|
positions_11_20: number;
|
||||||
positions_21_plus: number;
|
positions_21_plus: number;
|
||||||
};
|
};
|
||||||
top_keywords: Array<{ keyword: string; impressions: number; position: number }>;
|
top_keywords: Array<{ keyword: string; impressions: number; clicks: number; position: number; ctr: number }>;
|
||||||
top_pages: Array<{ page: string; clicks: number; impressions: number }>;
|
top_pages: Array<{ page: string; clicks: number; impressions: number; ctr: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrainstormResult {
|
export interface BrainstormResult {
|
||||||
error?: string;
|
error?: string;
|
||||||
content_opportunities: ContentOpportunity[];
|
content_opportunities: ContentOpportunity[];
|
||||||
keyword_gaps: KeywordGap[];
|
keyword_gaps: KeywordGap[];
|
||||||
|
quick_wins: QuickWin[];
|
||||||
|
page_opportunities: PageOpportunity[];
|
||||||
ai_recommendations: AIRecommendations | Record<string, never>;
|
ai_recommendations: AIRecommendations | Record<string, never>;
|
||||||
summary: BrainstormSummary | Record<string, never>;
|
summary: BrainstormSummary | Record<string, never>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useRef, useCallback, useState } from 'react';
|
import React, { useRef, useCallback, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
@@ -9,6 +9,7 @@ import Button from '@mui/material/Button';
|
|||||||
import { debug } from '../../utils/debug';
|
import { debug } from '../../utils/debug';
|
||||||
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
|
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
|
||||||
import { blogWriterApi } from '../../services/blogWriterApi';
|
import { blogWriterApi } from '../../services/blogWriterApi';
|
||||||
|
import { researchCache } from '../../services/researchCache';
|
||||||
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
import { useClaimFixer } from '../../hooks/useClaimFixer';
|
||||||
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
|
||||||
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
|
||||||
@@ -34,6 +35,7 @@ import { useModalVisibility } from './BlogWriterUtils/useModalVisibility';
|
|||||||
import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
|
import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
|
||||||
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
|
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
|
||||||
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
||||||
|
import { useBlogAsset } from '../../hooks/useBlogAsset';
|
||||||
|
|
||||||
const BlogWriter: React.FC = () => {
|
const BlogWriter: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -205,6 +207,8 @@ const BlogWriter: React.FC = () => {
|
|||||||
|
|
||||||
// Store navigateToPhase in a ref for use in polling callbacks
|
// Store navigateToPhase in a ref for use in polling callbacks
|
||||||
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
|
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
|
||||||
|
// When true (Re-Content), polling callback skips auto-confirm and SEO navigation
|
||||||
|
const skipContentAutoConfirmRef = React.useRef<boolean>(false);
|
||||||
|
|
||||||
// Normalize section keys to match outline IDs when updating from API responses
|
// Normalize section keys to match outline IDs when updating from API responses
|
||||||
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
|
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
|
||||||
@@ -221,6 +225,83 @@ const BlogWriter: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [outline, setSections]);
|
}, [outline, setSections]);
|
||||||
|
|
||||||
|
// Blog asset persistence (phase-by-phase saving via ContentAsset)
|
||||||
|
const {
|
||||||
|
assetId,
|
||||||
|
createAsset,
|
||||||
|
updatePhase,
|
||||||
|
loadAsset,
|
||||||
|
resetAsset,
|
||||||
|
} = useBlogAsset();
|
||||||
|
// Load blog asset passed via React Router state (from Asset Library)
|
||||||
|
const location = useLocation();
|
||||||
|
const locationState = location.state as { restoreBlogAssetId?: number } | null;
|
||||||
|
|
||||||
|
// Persist last active asset_id across refreshes
|
||||||
|
const saveLastAssetId = useCallback((id: number) => {
|
||||||
|
try { localStorage.setItem('blog_last_asset_id', id.toString()); } catch { /* noop */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const assetIdFromState = locationState?.restoreBlogAssetId;
|
||||||
|
if (assetIdFromState) {
|
||||||
|
// Coming from Asset Library — load that specific asset
|
||||||
|
loadAsset(assetIdFromState).then(loaded => {
|
||||||
|
if (!loaded) return;
|
||||||
|
saveLastAssetId(assetIdFromState);
|
||||||
|
debug.log('[BlogWriter] Loaded blog asset from navigation state', { asset_id: assetIdFromState, phase: loaded.phase });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No navigation state — try restoring last active asset from localStorage
|
||||||
|
const savedId = (() => { try { return localStorage.getItem('blog_last_asset_id'); } catch { return null; } })();
|
||||||
|
if (savedId) {
|
||||||
|
const id = parseInt(savedId, 10);
|
||||||
|
if (!isNaN(id)) {
|
||||||
|
loadAsset(id).then(loaded => {
|
||||||
|
if (loaded) {
|
||||||
|
debug.log('[BlogWriter] Restored last active blog', { asset_id: id, phase: loaded.phase });
|
||||||
|
} else {
|
||||||
|
// Asset was deleted or inaccessible — clear stale localStorage key
|
||||||
|
try { localStorage.removeItem('blog_last_asset_id'); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Create/get blog asset before research starts (saves to Asset Library immediately)
|
||||||
|
const handleBeforeResearchSubmit = useCallback(async (keywords: string, blogLength: string) => {
|
||||||
|
const id = await createAsset(keywords, keywords, parseInt(blogLength));
|
||||||
|
if (id) saveLastAssetId(id);
|
||||||
|
}, [createAsset, saveLastAssetId]);
|
||||||
|
|
||||||
|
// Wrap handlers to also update the blog ContentAsset
|
||||||
|
const wrappedHandleResearchComplete = useCallback((researchData: any) => {
|
||||||
|
handleResearchComplete(researchData);
|
||||||
|
if (assetId) { updatePhase('research', researchData); saveLastAssetId(assetId); }
|
||||||
|
}, [handleResearchComplete, assetId, updatePhase, saveLastAssetId]);
|
||||||
|
|
||||||
|
const wrappedHandleSEOAnalysisComplete = useCallback((analysis: any) => {
|
||||||
|
handleSEOAnalysisComplete(analysis);
|
||||||
|
if (assetId) { updatePhase('seo', analysis); saveLastAssetId(assetId); }
|
||||||
|
}, [handleSEOAnalysisComplete, assetId, updatePhase, saveLastAssetId]);
|
||||||
|
|
||||||
|
const wrappedHandleOutlineConfirmed = useCallback(() => {
|
||||||
|
handleOutlineConfirmed();
|
||||||
|
if (assetId) {
|
||||||
|
updatePhase('outline', { outline, selected_title: selectedTitle, title_options: titleOptions });
|
||||||
|
saveLastAssetId(assetId);
|
||||||
|
}
|
||||||
|
}, [handleOutlineConfirmed, assetId, updatePhase, outline, selectedTitle, titleOptions, saveLastAssetId]);
|
||||||
|
|
||||||
|
const wrappedConfirmBlogContent = useCallback(() => {
|
||||||
|
const result = confirmBlogContent();
|
||||||
|
if (assetId) { updatePhase('content', sections); saveLastAssetId(assetId); }
|
||||||
|
return result;
|
||||||
|
}, [confirmBlogContent, assetId, updatePhase, sections, saveLastAssetId]);
|
||||||
|
|
||||||
// Polling hooks - extracted to useBlogWriterPolling
|
// Polling hooks - extracted to useBlogWriterPolling
|
||||||
const {
|
const {
|
||||||
researchPolling,
|
researchPolling,
|
||||||
@@ -231,7 +312,7 @@ const BlogWriter: React.FC = () => {
|
|||||||
outlinePollingState,
|
outlinePollingState,
|
||||||
mediumPollingState,
|
mediumPollingState,
|
||||||
} = useBlogWriterPolling({
|
} = useBlogWriterPolling({
|
||||||
onResearchComplete: handleResearchComplete,
|
onResearchComplete: wrappedHandleResearchComplete,
|
||||||
onOutlineComplete: handleOutlineComplete,
|
onOutlineComplete: handleOutlineComplete,
|
||||||
onOutlineError: handleOutlineError,
|
onOutlineError: handleOutlineError,
|
||||||
onSectionsUpdate: handleSectionsUpdate,
|
onSectionsUpdate: handleSectionsUpdate,
|
||||||
@@ -239,6 +320,10 @@ const BlogWriter: React.FC = () => {
|
|||||||
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
|
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
|
||||||
setContentConfirmed(true);
|
setContentConfirmed(true);
|
||||||
},
|
},
|
||||||
|
onContentError: () => {
|
||||||
|
debug.log('[BlogWriter] Content generation failed - reverting outline confirmation');
|
||||||
|
setOutlineConfirmed(false);
|
||||||
|
},
|
||||||
navigateToPhase: (phase) => {
|
navigateToPhase: (phase) => {
|
||||||
debug.log('[BlogWriter] Navigating to phase after content generation', { phase });
|
debug.log('[BlogWriter] Navigating to phase after content generation', { phase });
|
||||||
// Use ref to access navigateToPhase (defined later in component)
|
// Use ref to access navigateToPhase (defined later in component)
|
||||||
@@ -248,6 +333,7 @@ const BlogWriter: React.FC = () => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
skipContentAutoConfirmRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Modal visibility management - extracted to useModalVisibility
|
// Modal visibility management - extracted to useModalVisibility
|
||||||
@@ -304,11 +390,13 @@ const BlogWriter: React.FC = () => {
|
|||||||
|
|
||||||
const handlePhaseClick = useCallback((phaseId: string) => {
|
const handlePhaseClick = useCallback((phaseId: string) => {
|
||||||
navigateToPhase(phaseId);
|
navigateToPhase(phaseId);
|
||||||
// When clicking Research phase, ensure we navigate to research phase (this will trigger research form to show)
|
if (phaseId === 'research') {
|
||||||
if (phaseId === 'research' && !research) {
|
if (!currentPhase) {
|
||||||
debug.log('[BlogWriter] Research phase clicked - navigating to research phase to show form');
|
setResearch(null);
|
||||||
// navigateToPhase already called above, which will set currentPhase to 'research'
|
debug.log('[BlogWriter] Research phase clicked from landing - cleared research to show form');
|
||||||
// BlogWriterLandingSection will detect currentPhase === 'research' and show ManualResearchForm
|
} else {
|
||||||
|
debug.log('[BlogWriter] Research phase clicked - showing existing research data');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (phaseId === 'seo') {
|
if (phaseId === 'seo') {
|
||||||
if (seoAnalysis) {
|
if (seoAnalysis) {
|
||||||
@@ -318,7 +406,7 @@ const BlogWriter: React.FC = () => {
|
|||||||
runSEOAnalysisDirect();
|
runSEOAnalysisDirect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [navigateToPhase, seoAnalysis, research, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
|
}, [navigateToPhase, currentPhase, seoAnalysis, research, setResearch, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
|
||||||
|
|
||||||
const handleNewBlog = useCallback(() => {
|
const handleNewBlog = useCallback(() => {
|
||||||
setResearch(null);
|
setResearch(null);
|
||||||
@@ -339,12 +427,16 @@ const BlogWriter: React.FC = () => {
|
|||||||
localStorage.removeItem('blogwriter_user_selected_phase');
|
localStorage.removeItem('blogwriter_user_selected_phase');
|
||||||
localStorage.removeItem('blog_content_confirmed');
|
localStorage.removeItem('blog_content_confirmed');
|
||||||
localStorage.removeItem('blog_seo_recommendations_applied');
|
localStorage.removeItem('blog_seo_recommendations_applied');
|
||||||
|
localStorage.removeItem('blog_last_asset_id');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore localStorage errors
|
// ignore localStorage errors
|
||||||
}
|
}
|
||||||
|
researchCache.clearCache();
|
||||||
|
resetAsset();
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
}, [setResearch, setOutline, setSections, setSeoAnalysis, setSeoMetadata,
|
}, [setResearch, setOutline, setSections, setSeoAnalysis, setSeoMetadata,
|
||||||
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
|
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
|
||||||
setCurrentPhase]);
|
setCurrentPhase, resetAsset, setSearchParams]);
|
||||||
|
|
||||||
// Handle ?new=true query param from "New Blog" button in Asset Library
|
// Handle ?new=true query param from "New Blog" button in Asset Library
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -354,12 +446,12 @@ const BlogWriter: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [searchParams, handleNewBlog, setSearchParams]);
|
}, [searchParams, handleNewBlog, setSearchParams]);
|
||||||
|
|
||||||
|
const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false);
|
||||||
|
|
||||||
const handleMyBlogs = useCallback(() => {
|
const handleMyBlogs = useCallback(() => {
|
||||||
navigate('/asset-library?source_module=blog_writer&asset_type=text');
|
navigate('/asset-library?source_module=blog_writer&asset_type=text');
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const hasExistingWork = !!(research || outline.length > 0 || Object.keys(sections).length > 0);
|
const hasExistingWork = !!(research || outline.length > 0 || Object.keys(sections).length > 0);
|
||||||
|
|
||||||
const confirmNewBlog = useCallback(() => {
|
const confirmNewBlog = useCallback(() => {
|
||||||
@@ -401,6 +493,7 @@ const BlogWriter: React.FC = () => {
|
|||||||
selectedTitle,
|
selectedTitle,
|
||||||
contentConfirmed,
|
contentConfirmed,
|
||||||
sections,
|
sections,
|
||||||
|
seoAnalysis,
|
||||||
navigateToPhase,
|
navigateToPhase,
|
||||||
handleOutlineConfirmed,
|
handleOutlineConfirmed,
|
||||||
setIsMediumGenerationStarting,
|
setIsMediumGenerationStarting,
|
||||||
@@ -411,7 +504,8 @@ const BlogWriter: React.FC = () => {
|
|||||||
setIsSEOAnalysisModalOpen,
|
setIsSEOAnalysisModalOpen,
|
||||||
setIsSEOMetadataModalOpen,
|
setIsSEOMetadataModalOpen,
|
||||||
runSEOAnalysisDirect,
|
runSEOAnalysisDirect,
|
||||||
onResearchComplete: handleResearchComplete,
|
skipContentAutoConfirmRef,
|
||||||
|
onResearchComplete: wrappedHandleResearchComplete,
|
||||||
onOutlineComplete: handleCachedOutlineComplete,
|
onOutlineComplete: handleCachedOutlineComplete,
|
||||||
onContentComplete: handleCachedContentComplete,
|
onContentComplete: handleCachedContentComplete,
|
||||||
});
|
});
|
||||||
@@ -433,7 +527,7 @@ const BlogWriter: React.FC = () => {
|
|||||||
isSEOAnalysisModalOpen,
|
isSEOAnalysisModalOpen,
|
||||||
lastSEOModalOpenRef,
|
lastSEOModalOpenRef,
|
||||||
runSEOAnalysisDirect,
|
runSEOAnalysisDirect,
|
||||||
confirmBlogContent,
|
confirmBlogContent: wrappedConfirmBlogContent,
|
||||||
sections,
|
sections,
|
||||||
research,
|
research,
|
||||||
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||||
@@ -461,11 +555,11 @@ const BlogWriter: React.FC = () => {
|
|||||||
outlineConfirmed={outlineConfirmed}
|
outlineConfirmed={outlineConfirmed}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
selectedTitle={selectedTitle}
|
selectedTitle={selectedTitle}
|
||||||
onResearchComplete={handleResearchComplete}
|
onResearchComplete={wrappedHandleResearchComplete}
|
||||||
onOutlineCreated={setOutline}
|
onOutlineCreated={setOutline}
|
||||||
onOutlineUpdated={setOutline}
|
onOutlineUpdated={setOutline}
|
||||||
onTitleOptionsSet={setTitleOptions}
|
onTitleOptionsSet={setTitleOptions}
|
||||||
onOutlineConfirmed={handleOutlineConfirmed}
|
onOutlineConfirmed={wrappedHandleOutlineConfirmed}
|
||||||
onOutlineRefined={(feedback?: string) => handleOutlineRefined(feedback || '')}
|
onOutlineRefined={(feedback?: string) => handleOutlineRefined(feedback || '')}
|
||||||
onMediumGenerationStarted={handleMediumGenerationStarted}
|
onMediumGenerationStarted={handleMediumGenerationStarted}
|
||||||
onMediumGenerationTriggered={handleMediumGenerationTriggered}
|
onMediumGenerationTriggered={handleMediumGenerationTriggered}
|
||||||
@@ -516,10 +610,21 @@ const BlogWriter: React.FC = () => {
|
|||||||
buildUpdatedMarkdownForClaim={buildUpdatedMarkdownForClaim}
|
buildUpdatedMarkdownForClaim={buildUpdatedMarkdownForClaim}
|
||||||
applyClaimFix={applyClaimFix}
|
applyClaimFix={applyClaimFix}
|
||||||
/>
|
/>
|
||||||
<Publisher
|
<Publisher
|
||||||
buildFullMarkdown={buildFullMarkdown}
|
buildFullMarkdown={buildFullMarkdown}
|
||||||
convertMarkdownToHTML={convertMarkdownToHTML}
|
convertMarkdownToHTML={convertMarkdownToHTML}
|
||||||
seoMetadata={seoMetadata}
|
seoMetadata={seoMetadata}
|
||||||
|
onPublishComplete={() => {
|
||||||
|
if (assetId) {
|
||||||
|
const fullContent = buildFullMarkdown();
|
||||||
|
updatePhase('publish', {
|
||||||
|
published_at: new Date().toISOString(),
|
||||||
|
content_preview: fullContent.substring(0, 500),
|
||||||
|
title: selectedTitle || seoMetadata?.seo_title || '',
|
||||||
|
});
|
||||||
|
saveLastAssetId(assetId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Phase navigation header - always visible as default interface */}
|
{/* Phase navigation header - always visible as default interface */}
|
||||||
@@ -540,7 +645,7 @@ const BlogWriter: React.FC = () => {
|
|||||||
hasResearch={!!research}
|
hasResearch={!!research}
|
||||||
hasOutline={outline.length > 0}
|
hasOutline={outline.length > 0}
|
||||||
outlineConfirmed={outlineConfirmed}
|
outlineConfirmed={outlineConfirmed}
|
||||||
hasContent={Object.keys(sections).length > 0}
|
hasContent={hasContent}
|
||||||
contentConfirmed={contentConfirmed}
|
contentConfirmed={contentConfirmed}
|
||||||
hasSEOAnalysis={!!seoAnalysis}
|
hasSEOAnalysis={!!seoAnalysis}
|
||||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||||
@@ -557,7 +662,8 @@ const BlogWriter: React.FC = () => {
|
|||||||
copilotKitAvailable={copilotKitAvailable}
|
copilotKitAvailable={copilotKitAvailable}
|
||||||
currentPhase={currentPhase}
|
currentPhase={currentPhase}
|
||||||
navigateToPhase={navigateToPhase}
|
navigateToPhase={navigateToPhase}
|
||||||
onResearchComplete={handleResearchComplete}
|
onResearchComplete={wrappedHandleResearchComplete}
|
||||||
|
onBeforeResearchSubmit={handleBeforeResearchSubmit}
|
||||||
restoreAttempted={restoreAttempted}
|
restoreAttempted={restoreAttempted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -592,7 +698,7 @@ const BlogWriter: React.FC = () => {
|
|||||||
onTitleSelect={handleTitleSelect}
|
onTitleSelect={handleTitleSelect}
|
||||||
onCustomTitle={handleCustomTitle}
|
onCustomTitle={handleCustomTitle}
|
||||||
copilotKitAvailable={copilotKitAvailable}
|
copilotKitAvailable={copilotKitAvailable}
|
||||||
onResearchComplete={handleResearchComplete}
|
onResearchComplete={wrappedHandleResearchComplete}
|
||||||
onOutlineGenerationStart={(taskId) => {
|
onOutlineGenerationStart={(taskId) => {
|
||||||
setOutlineTaskId(taskId);
|
setOutlineTaskId(taskId);
|
||||||
outlinePolling.startPolling(taskId);
|
outlinePolling.startPolling(taskId);
|
||||||
@@ -628,7 +734,7 @@ const BlogWriter: React.FC = () => {
|
|||||||
blogTitle={selectedTitle}
|
blogTitle={selectedTitle}
|
||||||
researchData={research}
|
researchData={research}
|
||||||
onApplyRecommendations={handleApplySeoRecommendations}
|
onApplyRecommendations={handleApplySeoRecommendations}
|
||||||
onAnalysisComplete={handleSEOAnalysisComplete}
|
onAnalysisComplete={wrappedHandleSEOAnalysisComplete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* SEO Metadata Modal */}
|
{/* SEO Metadata Modal */}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface BlogWriterLandingSectionProps {
|
|||||||
currentPhase: string;
|
currentPhase: string;
|
||||||
navigateToPhase: (phase: string) => void;
|
navigateToPhase: (phase: string) => void;
|
||||||
onResearchComplete: (research: any) => void;
|
onResearchComplete: (research: any) => void;
|
||||||
|
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
|
||||||
restoreAttempted?: boolean;
|
restoreAttempted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,11 +21,12 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
|
|||||||
currentPhase,
|
currentPhase,
|
||||||
navigateToPhase,
|
navigateToPhase,
|
||||||
onResearchComplete,
|
onResearchComplete,
|
||||||
|
onBeforeResearchSubmit,
|
||||||
restoreAttempted = false,
|
restoreAttempted = false,
|
||||||
}) => {
|
}) => {
|
||||||
if (!research) {
|
if (!research) {
|
||||||
if (currentPhase === 'research') {
|
if (currentPhase === 'research') {
|
||||||
return <ManualResearchForm onResearchComplete={onResearchComplete} />;
|
return <ManualResearchForm onResearchComplete={onResearchComplete} onBeforeResearchSubmit={onBeforeResearchSubmit} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {
|
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useRewritePolling,
|
useRewritePolling,
|
||||||
} from '../../../hooks/usePolling';
|
} from '../../../hooks/usePolling';
|
||||||
import { blogWriterCache } from '../../../services/blogWriterCache';
|
import { blogWriterCache } from '../../../services/blogWriterCache';
|
||||||
|
import { debug } from '../../../utils/debug';
|
||||||
|
|
||||||
interface UseBlogWriterPollingProps {
|
interface UseBlogWriterPollingProps {
|
||||||
onResearchComplete: (research: any) => void;
|
onResearchComplete: (research: any) => void;
|
||||||
@@ -13,7 +14,9 @@ interface UseBlogWriterPollingProps {
|
|||||||
onOutlineError: (error: any) => void;
|
onOutlineError: (error: any) => void;
|
||||||
onSectionsUpdate: (sections: Record<string, string>) => void;
|
onSectionsUpdate: (sections: Record<string, string>) => void;
|
||||||
onContentConfirmed?: () => void; // Callback when content generation completes
|
onContentConfirmed?: () => void; // Callback when content generation completes
|
||||||
|
onContentError?: () => void; // Callback when content generation fails
|
||||||
navigateToPhase?: (phase: string) => void; // Phase navigation function
|
navigateToPhase?: (phase: string) => void; // Phase navigation function
|
||||||
|
skipContentAutoConfirmRef?: React.MutableRefObject<boolean>; // When true, skip auto-confirm & navigation after content generation
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBlogWriterPolling = ({
|
export const useBlogWriterPolling = ({
|
||||||
@@ -22,7 +25,9 @@ export const useBlogWriterPolling = ({
|
|||||||
onOutlineError,
|
onOutlineError,
|
||||||
onSectionsUpdate,
|
onSectionsUpdate,
|
||||||
onContentConfirmed,
|
onContentConfirmed,
|
||||||
|
onContentError,
|
||||||
navigateToPhase,
|
navigateToPhase,
|
||||||
|
skipContentAutoConfirmRef,
|
||||||
}: UseBlogWriterPollingProps) => {
|
}: UseBlogWriterPollingProps) => {
|
||||||
// Research polling hook (for context awareness) - uses blog writer endpoint
|
// Research polling hook (for context awareness) - uses blog writer endpoint
|
||||||
const researchPolling = useBlogWriterResearchPolling({
|
const researchPolling = useBlogWriterResearchPolling({
|
||||||
@@ -47,36 +52,22 @@ export const useBlogWriterPolling = ({
|
|||||||
});
|
});
|
||||||
onSectionsUpdate(newSections);
|
onSectionsUpdate(newSections);
|
||||||
|
|
||||||
// Auto-confirm content and navigate to SEO phase when content generation completes
|
// Skip auto-confirm and navigation when Re-Content was used
|
||||||
// This happens when user clicks "Next:Confirm and generate content"
|
// (user already had content and chose to regenerate — stay on content phase to review)
|
||||||
if (onContentConfirmed) {
|
const skipAutoConfirm = skipContentAutoConfirmRef?.current === true;
|
||||||
onContentConfirmed();
|
if (skipContentAutoConfirmRef) skipContentAutoConfirmRef.current = false; // reset flag
|
||||||
}
|
if (skipAutoConfirm) {
|
||||||
if (navigateToPhase) {
|
debug.log('[BlogWriter] Re-Content: skipping auto-confirm and navigation (user stays on content phase)');
|
||||||
navigateToPhase('seo');
|
} else {
|
||||||
}
|
// Auto-confirm content and navigate to SEO phase when content generation completes
|
||||||
|
// This happens for initial content generation (first time)
|
||||||
// Save to asset library (dedup by title is handled inside saveBlogToAssetLibrary)
|
if (onContentConfirmed) {
|
||||||
// Backend also saves via save_and_track_text_content; this is a safety net / metadata update
|
onContentConfirmed();
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const { saveBlogToAssetLibrary } = await import('../../../services/blogWriterApi');
|
|
||||||
const totalWords = result.sections.reduce(
|
|
||||||
(sum: number, s: any) => sum + (s.wordCount || (s.content || '').split(/\s+/).length),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
await saveBlogToAssetLibrary({
|
|
||||||
title: result.title || 'Untitled Blog',
|
|
||||||
blogType: 'medium',
|
|
||||||
wordCount: totalWords,
|
|
||||||
sectionCount: result.sections?.length,
|
|
||||||
model: result.model,
|
|
||||||
generationTimeMs: result.generation_time_ms,
|
|
||||||
});
|
|
||||||
} catch (assetError) {
|
|
||||||
console.error('[BlogWriter] Failed to save blog to asset library:', assetError);
|
|
||||||
}
|
}
|
||||||
})();
|
if (navigateToPhase) {
|
||||||
|
navigateToPhase('seo');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to apply medium generation result:', e);
|
console.error('Failed to apply medium generation result:', e);
|
||||||
@@ -84,11 +75,12 @@ export const useBlogWriterPolling = ({
|
|||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
console.error('Medium generation failed:', err);
|
console.error('Medium generation failed:', err);
|
||||||
|
onContentError?.();
|
||||||
const errMsg = (typeof err === 'string' ? err : (err?.message || err?.error || '')).toLowerCase();
|
const errMsg = (typeof err === 'string' ? err : (err?.message || err?.error || '')).toLowerCase();
|
||||||
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
|
if (errMsg.includes('insufficient_balance') || errMsg.includes('balance_not_enough') || (errMsg.includes('403') && errMsg.includes('balance'))) {
|
||||||
setTimeout(() => alert('Your API balance is insufficient. Please top up your account or switch to a different provider.'), 100);
|
setTimeout(() => alert('Your API balance is insufficient. Please top up your account or switch to a different provider.'), 100);
|
||||||
} else if (errMsg.includes('no valid structured response')) {
|
} else if (errMsg.includes('no valid structured response') || errMsg.includes('parse') || errMsg.includes('json')) {
|
||||||
setTimeout(() => alert('Content generation failed due to a provider error. This might be a temporary issue — please try again or switch providers.'), 100);
|
setTimeout(() => alert('Content generation failed because the AI provider returned an unparseable response. This is usually a temporary issue — please try again.'), 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
interface UseModalVisibilityProps {
|
interface UseModalVisibilityProps {
|
||||||
mediumPolling: { isPolling: boolean };
|
mediumPolling: { isPolling: boolean };
|
||||||
@@ -37,16 +37,24 @@ export const useModalVisibility = ({
|
|||||||
}
|
}
|
||||||
}, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
|
}, [mediumPolling.isPolling, rewritePolling.isPolling, isMediumGenerationStarting, showModal, modalStartTime]);
|
||||||
|
|
||||||
// Handle outline modal visibility
|
// Handle outline modal visibility with proper timeout cleanup
|
||||||
|
const outlineHideRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (outlinePolling.isPolling && !showOutlineModal) {
|
if (outlinePolling.isPolling && !showOutlineModal) {
|
||||||
setShowOutlineModal(true);
|
setShowOutlineModal(true);
|
||||||
} else if (!outlinePolling.isPolling && showOutlineModal) {
|
} else if (!outlinePolling.isPolling && showOutlineModal) {
|
||||||
// Add a small delay to ensure user sees completion message
|
outlineHideRef.current = setTimeout(() => {
|
||||||
setTimeout(() => {
|
|
||||||
setShowOutlineModal(false);
|
setShowOutlineModal(false);
|
||||||
|
outlineHideRef.current = null;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
if (outlineHideRef.current) {
|
||||||
|
clearTimeout(outlineHideRef.current);
|
||||||
|
outlineHideRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
}, [outlinePolling.isPolling, showOutlineModal]);
|
}, [outlinePolling.isPolling, showOutlineModal]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface UsePhaseActionHandlersProps {
|
|||||||
selectedTitle: string | null;
|
selectedTitle: string | null;
|
||||||
contentConfirmed: boolean;
|
contentConfirmed: boolean;
|
||||||
sections: Record<string, string>;
|
sections: Record<string, string>;
|
||||||
|
seoAnalysis: any;
|
||||||
navigateToPhase: (phase: string) => void;
|
navigateToPhase: (phase: string) => void;
|
||||||
handleOutlineConfirmed: () => void;
|
handleOutlineConfirmed: () => void;
|
||||||
setIsMediumGenerationStarting: (starting: boolean) => void;
|
setIsMediumGenerationStarting: (starting: boolean) => void;
|
||||||
@@ -20,6 +21,7 @@ interface UsePhaseActionHandlersProps {
|
|||||||
setIsSEOAnalysisModalOpen: (open: boolean) => void;
|
setIsSEOAnalysisModalOpen: (open: boolean) => void;
|
||||||
setIsSEOMetadataModalOpen: (open: boolean) => void;
|
setIsSEOMetadataModalOpen: (open: boolean) => void;
|
||||||
runSEOAnalysisDirect: () => string;
|
runSEOAnalysisDirect: () => string;
|
||||||
|
skipContentAutoConfirmRef?: React.MutableRefObject<boolean>;
|
||||||
onResearchComplete?: (research: any) => void;
|
onResearchComplete?: (research: any) => void;
|
||||||
onOutlineComplete?: (outline: any) => void;
|
onOutlineComplete?: (outline: any) => void;
|
||||||
onContentComplete?: (sections: Record<string, string>) => void;
|
onContentComplete?: (sections: Record<string, string>) => void;
|
||||||
@@ -31,6 +33,7 @@ export const usePhaseActionHandlers = ({
|
|||||||
selectedTitle,
|
selectedTitle,
|
||||||
contentConfirmed,
|
contentConfirmed,
|
||||||
sections,
|
sections,
|
||||||
|
seoAnalysis,
|
||||||
navigateToPhase,
|
navigateToPhase,
|
||||||
handleOutlineConfirmed,
|
handleOutlineConfirmed,
|
||||||
setIsMediumGenerationStarting,
|
setIsMediumGenerationStarting,
|
||||||
@@ -41,32 +44,14 @@ export const usePhaseActionHandlers = ({
|
|||||||
setIsSEOAnalysisModalOpen,
|
setIsSEOAnalysisModalOpen,
|
||||||
setIsSEOMetadataModalOpen,
|
setIsSEOMetadataModalOpen,
|
||||||
runSEOAnalysisDirect,
|
runSEOAnalysisDirect,
|
||||||
|
skipContentAutoConfirmRef,
|
||||||
onResearchComplete,
|
onResearchComplete,
|
||||||
onOutlineComplete,
|
onOutlineComplete,
|
||||||
onContentComplete,
|
onContentComplete,
|
||||||
}: UsePhaseActionHandlersProps) => {
|
}: UsePhaseActionHandlersProps) => {
|
||||||
const handleResearchAction = useCallback(() => {
|
const handleResearchAction = useCallback(() => {
|
||||||
if (research) {
|
|
||||||
navigateToPhase('research');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cachedEntries = researchCache.getAllCachedEntries();
|
|
||||||
const latestCached = cachedEntries.find(entry => {
|
|
||||||
try {
|
|
||||||
return new Date(entry.expires_at) > new Date();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (latestCached && onResearchComplete) {
|
|
||||||
debug.log('[BlogWriter] Restoring cached research data', { keywords: latestCached.keywords });
|
|
||||||
onResearchComplete(latestCached.result);
|
|
||||||
}
|
|
||||||
|
|
||||||
navigateToPhase('research');
|
navigateToPhase('research');
|
||||||
}, [navigateToPhase, onResearchComplete, research]);
|
}, [navigateToPhase]);
|
||||||
|
|
||||||
const handleOutlineAction = useCallback(async () => {
|
const handleOutlineAction = useCallback(async () => {
|
||||||
if (!research) {
|
if (!research) {
|
||||||
@@ -105,7 +90,7 @@ export const usePhaseActionHandlers = ({
|
|||||||
|
|
||||||
const handleContentAction = useCallback(async () => {
|
const handleContentAction = useCallback(async () => {
|
||||||
if (!outline || outline.length === 0) {
|
if (!outline || outline.length === 0) {
|
||||||
alert('Please generate and confirm an outline first.');
|
alert('Please generate an outline first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!research) {
|
if (!research) {
|
||||||
@@ -117,22 +102,33 @@ export const usePhaseActionHandlers = ({
|
|||||||
// Confirm outline first
|
// Confirm outline first
|
||||||
handleOutlineConfirmed();
|
handleOutlineConfirmed();
|
||||||
|
|
||||||
// Check cache first (shared utility)
|
|
||||||
const outlineIds = outline.map(s => String(s.id));
|
const outlineIds = outline.map(s => String(s.id));
|
||||||
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
const hasExistingContent = sections && Object.keys(sections).length > 0 && Object.values(sections).some(c => c?.trim());
|
||||||
|
|
||||||
if (cachedContent) {
|
// Signal to polling callback: if content was already confirmed (Re-Content),
|
||||||
debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length });
|
// skip auto-confirm and SEO navigation so user stays on content phase to review
|
||||||
if (onContentComplete) {
|
if (skipContentAutoConfirmRef && hasExistingContent) {
|
||||||
onContentComplete(cachedContent);
|
skipContentAutoConfirmRef.current = true;
|
||||||
}
|
debug.log('[BlogWriter] Re-Content: setting skipAutoConfirm flag');
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also check if sections already exist in current state (shared utility)
|
// Only use cache for initial generation (when no content exists yet).
|
||||||
if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) {
|
// "Re-Content" label means user explicitly wants to regenerate, so skip cache.
|
||||||
debug.log('[BlogWriter] Content already exists in state, skipping generation', { sections: Object.keys(sections || {}).length });
|
if (!hasExistingContent) {
|
||||||
return;
|
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
|
||||||
|
if (cachedContent) {
|
||||||
|
debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length });
|
||||||
|
if (onContentComplete) {
|
||||||
|
onContentComplete(cachedContent);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) {
|
||||||
|
debug.log('[BlogWriter] Content already exists in state, skipping generation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug.log('[BlogWriter] Content exists - regenerating per user request');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If short/medium blog (<=1000 words), trigger content generation automatically
|
// If short/medium blog (<=1000 words), trigger content generation automatically
|
||||||
@@ -183,13 +179,17 @@ export const usePhaseActionHandlers = ({
|
|||||||
|
|
||||||
const handleSEOAction = useCallback(() => {
|
const handleSEOAction = useCallback(() => {
|
||||||
if (!contentConfirmed) {
|
if (!contentConfirmed) {
|
||||||
// Mark content as confirmed when SEO action is clicked
|
|
||||||
setContentConfirmed(true);
|
setContentConfirmed(true);
|
||||||
}
|
}
|
||||||
navigateToPhase('seo');
|
navigateToPhase('seo');
|
||||||
runSEOAnalysisDirect();
|
if (seoAnalysis) {
|
||||||
debug.log('[BlogWriter] SEO action triggered - running SEO analysis');
|
setIsSEOAnalysisModalOpen(true);
|
||||||
}, [contentConfirmed, setContentConfirmed, navigateToPhase, runSEOAnalysisDirect]);
|
debug.log('[BlogWriter] SEO analysis exists - opening modal for review');
|
||||||
|
} else {
|
||||||
|
runSEOAnalysisDirect();
|
||||||
|
debug.log('[BlogWriter] SEO action triggered - running SEO analysis');
|
||||||
|
}
|
||||||
|
}, [contentConfirmed, seoAnalysis, setContentConfirmed, navigateToPhase, setIsSEOAnalysisModalOpen, runSEOAnalysisDirect]);
|
||||||
|
|
||||||
const handleApplySEORecommendations = useCallback(() => {
|
const handleApplySEORecommendations = useCallback(() => {
|
||||||
navigateToPhase('seo');
|
navigateToPhase('seo');
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
|||||||
brainstormError,
|
brainstormError,
|
||||||
contentOpportunities,
|
contentOpportunities,
|
||||||
keywordGaps,
|
keywordGaps,
|
||||||
|
quickWins,
|
||||||
|
pageOpportunities,
|
||||||
aiRecommendations,
|
aiRecommendations,
|
||||||
summary,
|
summary,
|
||||||
|
progressMessage,
|
||||||
connectGSC,
|
connectGSC,
|
||||||
brainstorm,
|
brainstorm,
|
||||||
reset,
|
reset,
|
||||||
@@ -36,7 +39,6 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
|||||||
const wordCount = keywords.trim().split(/\s+/).filter(Boolean).length;
|
const wordCount = keywords.trim().split(/\s+/).filter(Boolean).length;
|
||||||
const isVisible = wordCount >= 3;
|
const isVisible = wordCount >= 3;
|
||||||
|
|
||||||
// Auto-trigger brainstorm after GSC connection succeeds
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gscConnected && pendingBrainstormRef.current && !isConnecting) {
|
if (gscConnected && pendingBrainstormRef.current && !isConnecting) {
|
||||||
pendingBrainstormRef.current = false;
|
pendingBrainstormRef.current = false;
|
||||||
@@ -100,7 +102,7 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
|||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 20px',
|
padding: '12px 20px',
|
||||||
backgroundColor: disabled || isBrainstorming ? '#ccc' : '#4caf50',
|
backgroundColor: disabled || isBrainstorming ? '#999' : '#4caf50',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
@@ -144,10 +146,13 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
|||||||
}}
|
}}
|
||||||
contentOpportunities={contentOpportunities}
|
contentOpportunities={contentOpportunities}
|
||||||
keywordGaps={keywordGaps}
|
keywordGaps={keywordGaps}
|
||||||
|
quickWins={quickWins}
|
||||||
|
pageOpportunities={pageOpportunities}
|
||||||
aiRecommendations={aiRecommendations}
|
aiRecommendations={aiRecommendations}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
error={brainstormError}
|
error={brainstormError}
|
||||||
isBrainstorming={isBrainstorming}
|
isBrainstorming={isBrainstorming}
|
||||||
|
progressMessage={progressMessage}
|
||||||
onSelectSuggestion={handleSelectSuggestion}
|
onSelectSuggestion={handleSelectSuggestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -165,10 +170,6 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* GSC Connection Overlay */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const GSConnectOverlay: React.FC<{
|
const GSConnectOverlay: React.FC<{
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
connectError: string | null;
|
connectError: string | null;
|
||||||
@@ -177,7 +178,6 @@ const GSConnectOverlay: React.FC<{
|
|||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}> = ({ isConnecting, connectError, gscConnected, onConnect, onSuccess, onCancel }) => {
|
}> = ({ isConnecting, connectError, gscConnected, onConnect, onSuccess, onCancel }) => {
|
||||||
// If connection just succeeded, auto-proceed
|
|
||||||
if (gscConnected && !isConnecting) {
|
if (gscConnected && !isConnecting) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import React from 'react';
|
|||||||
import {
|
import {
|
||||||
ContentOpportunity,
|
ContentOpportunity,
|
||||||
KeywordGap,
|
KeywordGap,
|
||||||
|
QuickWin,
|
||||||
|
PageOpportunity,
|
||||||
AIRecommendations,
|
AIRecommendations,
|
||||||
|
AIRecommendation,
|
||||||
BrainstormSummary,
|
BrainstormSummary,
|
||||||
} from '../../api/gscBrainstorm';
|
} from '../../api/gscBrainstorm';
|
||||||
|
|
||||||
@@ -11,14 +14,23 @@ interface GSCBrainstormModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
contentOpportunities: ContentOpportunity[];
|
contentOpportunities: ContentOpportunity[];
|
||||||
keywordGaps: KeywordGap[];
|
keywordGaps: KeywordGap[];
|
||||||
|
quickWins: QuickWin[];
|
||||||
|
pageOpportunities: PageOpportunity[];
|
||||||
aiRecommendations: AIRecommendations | null;
|
aiRecommendations: AIRecommendations | null;
|
||||||
summary: BrainstormSummary | null;
|
summary: BrainstormSummary | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isBrainstorming: boolean;
|
isBrainstorming: boolean;
|
||||||
|
progressMessage?: string;
|
||||||
onSelectSuggestion: (keyword: string) => void;
|
onSelectSuggestion: (keyword: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabLabels = ['Opportunities', 'Keyword Gaps', 'AI Recommendations'] as const;
|
const tabLabels = [
|
||||||
|
'Quick Wins',
|
||||||
|
'Opportunities',
|
||||||
|
'Keyword Gaps',
|
||||||
|
'Pages',
|
||||||
|
'AI Recommendations',
|
||||||
|
] as const;
|
||||||
type TabKey = typeof tabLabels[number];
|
type TabKey = typeof tabLabels[number];
|
||||||
|
|
||||||
export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
||||||
@@ -26,225 +38,223 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
contentOpportunities,
|
contentOpportunities,
|
||||||
keywordGaps,
|
keywordGaps,
|
||||||
|
quickWins,
|
||||||
|
pageOpportunities,
|
||||||
aiRecommendations,
|
aiRecommendations,
|
||||||
summary,
|
summary,
|
||||||
error,
|
error,
|
||||||
isBrainstorming,
|
isBrainstorming,
|
||||||
|
progressMessage,
|
||||||
onSelectSuggestion,
|
onSelectSuggestion,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = React.useState<TabKey>('Opportunities');
|
const [activeTab, setActiveTab] = React.useState<TabKey>('Quick Wins');
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const hasNoData =
|
const hasData =
|
||||||
!isBrainstorming &&
|
contentOpportunities.length > 0 ||
|
||||||
!error &&
|
keywordGaps.length > 0 ||
|
||||||
contentOpportunities.length === 0 &&
|
quickWins.length > 0 ||
|
||||||
keywordGaps.length === 0 &&
|
pageOpportunities.length > 0 ||
|
||||||
!aiRecommendations;
|
aiRecommendations !== null;
|
||||||
|
|
||||||
|
const getTabCount = (tab: TabKey): number => {
|
||||||
|
switch (tab) {
|
||||||
|
case 'Quick Wins': return quickWins.length;
|
||||||
|
case 'Opportunities': return contentOpportunities.length;
|
||||||
|
case 'Keyword Gaps': return keywordGaps.length;
|
||||||
|
case 'Pages': return pageOpportunities.length;
|
||||||
|
case 'AI Recommendations':
|
||||||
|
return aiRecommendations
|
||||||
|
? (aiRecommendations.immediate_opportunities?.length ?? 0) +
|
||||||
|
(aiRecommendations.content_strategy?.length ?? 0) +
|
||||||
|
(aiRecommendations.long_term_strategy?.length ?? 0)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
top: 0,
|
backgroundColor: 'rgba(0,0,0,0.55)', display: 'flex',
|
||||||
left: 0,
|
alignItems: 'center', justifyContent: 'center', zIndex: 9999,
|
||||||
right: 0,
|
backdropFilter: 'blur(2px)',
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 9999,
|
|
||||||
}}
|
}}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderRadius: '12px',
|
borderRadius: '16px',
|
||||||
width: '90%',
|
width: '85vw',
|
||||||
maxWidth: '720px',
|
height: '85vh',
|
||||||
maxHeight: '85vh',
|
maxWidth: '1200px',
|
||||||
|
maxHeight: '900px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
boxShadow: '0 16px 48px rgba(0,0,0,0.25)',
|
||||||
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
display: 'flex',
|
padding: '20px 28px', borderBottom: '1px solid #e8e8e8', flexShrink: 0,
|
||||||
justifyContent: 'space-between',
|
}}>
|
||||||
alignItems: 'center',
|
|
||||||
padding: '16px 24px',
|
|
||||||
borderBottom: '1px solid #e0e0e0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0, fontSize: '18px', color: '#333' }}>
|
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#1a1a1a' }}>
|
||||||
Brainstorm Topics with GSC Data
|
Brainstorm Topics with GSC Data
|
||||||
</h3>
|
</h3>
|
||||||
{summary && (
|
{summary?.site_url && (
|
||||||
<p style={{ margin: '4px 0 0', fontSize: '12px', color: '#888' }}>
|
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#888' }}>
|
||||||
{summary.site_url} · {summary.date_range?.start} to {summary.date_range?.end} ·{' '}
|
{summary.site_url} · {summary.date_range?.start} to {summary.date_range?.end} ·{' '}
|
||||||
{summary.total_keywords_analyzed} keywords analyzed
|
{summary.total_keywords_analyzed} keywords
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none', border: 'none', fontSize: '22px', cursor: 'pointer',
|
||||||
border: 'none',
|
color: '#999', padding: '4px 10px', borderRadius: '6px',
|
||||||
fontSize: '20px',
|
transition: 'background-color 0.15s', lineHeight: 1,
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#888',
|
|
||||||
padding: '4px 8px',
|
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>✕</button>
|
||||||
x
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary metrics bar */}
|
{/* Summary dashboard */}
|
||||||
{summary && summary.total_keywords_analyzed > 0 && (
|
{summary && summary.total_keywords_analyzed > 0 && (
|
||||||
<div
|
<SummaryDashboard summary={summary} />
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: '16px',
|
|
||||||
padding: '12px 24px',
|
|
||||||
backgroundColor: '#f0f7ff',
|
|
||||||
borderBottom: '1px solid #e0e0e0',
|
|
||||||
fontSize: '13px',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<strong>{summary.total_impressions?.toLocaleString()}</strong> impressions
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>{summary.total_clicks?.toLocaleString()}</strong> clicks
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>{summary.avg_ctr}%</strong> avg CTR
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<strong>{summary.avg_position}</strong> avg position
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading */}
|
{/* Loading with educational progress */}
|
||||||
{isBrainstorming && (
|
{isBrainstorming && (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
padding: '48px 24px',
|
alignItems: 'center', justifyContent: 'center',
|
||||||
textAlign: 'center',
|
padding: '48px', gap: '24px',
|
||||||
}}
|
}}>
|
||||||
>
|
<div style={{ position: 'relative', width: '72px', height: '72px' }}>
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
position: 'absolute', inset: 0,
|
||||||
width: '40px',
|
borderRadius: '50%', border: '4px solid #e8e8e8',
|
||||||
height: '40px',
|
}} />
|
||||||
border: '3px solid #e0e0e0',
|
<div style={{
|
||||||
borderTopColor: '#1976d2',
|
position: 'absolute', inset: 0,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%', border: '4px solid transparent',
|
||||||
animation: 'spin 1s linear infinite',
|
borderTopColor: '#1976d2', borderRightColor: '#4caf50',
|
||||||
margin: '0 auto 16px',
|
animation: 'progressSpin 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite',
|
||||||
}}
|
}} />
|
||||||
/>
|
<style>{`@keyframes progressSpin { to { transform: rotate(360deg); } }`}</style>
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
</div>
|
||||||
<p style={{ color: '#666', margin: 0 }}>
|
<div style={{ textAlign: 'center', maxWidth: '520px' }}>
|
||||||
Analyzing your GSC data and generating topic suggestions...
|
{progressMessage ? (
|
||||||
</p>
|
<>
|
||||||
|
<p style={{
|
||||||
|
margin: '0 0 12px', fontSize: '15px', color: '#333',
|
||||||
|
fontWeight: 500, lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
{progressMessage}
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
width: '240px', height: '3px', backgroundColor: '#e8e8e8',
|
||||||
|
borderRadius: '2px', margin: '0 auto', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '40%', height: '100%', backgroundColor: '#4caf50',
|
||||||
|
borderRadius: '2px',
|
||||||
|
animation: 'progressBar 2s ease-in-out infinite',
|
||||||
|
}} />
|
||||||
|
<style>{`@keyframes progressBar { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }`}</style>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ margin: 0, fontSize: '15px', color: '#666', lineHeight: 1.5 }}>
|
||||||
|
Analyzing your GSC data and generating topic suggestions...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p style={{ margin: '16px 0 0', fontSize: '13px', color: '#999' }}>
|
||||||
|
This usually takes 5-15 seconds
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#f8fbff', borderRadius: '10px',
|
||||||
|
padding: '16px 20px', maxWidth: '480px', width: '100%',
|
||||||
|
border: '1px solid #e0ecf7',
|
||||||
|
}}>
|
||||||
|
<p style={{ margin: '0 0 6px', fontSize: '12px', fontWeight: 600, color: '#1565c0' }}>
|
||||||
|
What's happening behind the scenes:
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: 0, fontSize: '12px', color: '#555', lineHeight: 1.5 }}>
|
||||||
|
We fetch your real Google Search Console data, scan for high-ROI keywords,
|
||||||
|
find pages that need optimization, and ask our AI to craft topic suggestions
|
||||||
|
tailored to your site's analytics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && !isBrainstorming && (
|
{error && !isBrainstorming && (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
padding: '24px',
|
alignItems: 'center', justifyContent: 'center', padding: '48px',
|
||||||
textAlign: 'center',
|
}}>
|
||||||
}}
|
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.6 }}>⚠</div>
|
||||||
>
|
<p style={{ color: '#d32f2f', margin: '0 0 8px', fontWeight: 500, fontSize: '15px' }}>{error}</p>
|
||||||
<p style={{ color: '#d32f2f', margin: '0 0 8px', fontWeight: 500 }}>
|
<p style={{ color: '#888', margin: 0, fontSize: '13px' }}>Make sure your Google Search Console is connected and has data for the last 30 days.</p>
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
<p style={{ color: '#888', margin: 0, fontSize: '13px' }}>
|
|
||||||
Make sure your Google Search Console is connected and has data for the last 30 days.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No data */}
|
{/* No data */}
|
||||||
{hasNoData && (
|
{!isBrainstorming && !error && !hasData && (
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
padding: '48px 24px',
|
alignItems: 'center', justifyContent: 'center', padding: '48px',
|
||||||
textAlign: 'center',
|
}}>
|
||||||
}}
|
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.4 }}>🔍</div>
|
||||||
>
|
<p style={{ color: '#888', margin: 0 }}>No brainstorming data available. Try different keywords or check your GSC connection.</p>
|
||||||
<p style={{ color: '#888', margin: 0 }}>
|
|
||||||
No brainstorming data available. Try different keywords or check your GSC connection.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{!isBrainstorming && !error && !hasNoData && (
|
{!isBrainstorming && !error && hasData && (
|
||||||
<>
|
<>
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
display: 'flex', borderBottom: '1px solid #e8e8e8',
|
||||||
display: 'flex',
|
backgroundColor: '#fafafa', padding: '0 4px', flexShrink: 0,
|
||||||
borderBottom: '1px solid #e0e0e0',
|
}}>
|
||||||
backgroundColor: '#fafafa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tabLabels.map((tab) => {
|
{tabLabels.map((tab) => {
|
||||||
const count =
|
const count = getTabCount(tab);
|
||||||
tab === 'Opportunities'
|
const isActive = activeTab === tab;
|
||||||
? contentOpportunities.length
|
|
||||||
: tab === 'Keyword Gaps'
|
|
||||||
? keywordGaps.length
|
|
||||||
: aiRecommendations
|
|
||||||
? (aiRecommendations.immediate_opportunities?.length ?? 0) +
|
|
||||||
(aiRecommendations.content_strategy?.length ?? 0) +
|
|
||||||
(aiRecommendations.long_term_strategy?.length ?? 0)
|
|
||||||
: 0;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 20px',
|
padding: '12px 20px', border: 'none',
|
||||||
border: 'none',
|
borderBottom: isActive ? '2px solid #1976d2' : '2px solid transparent',
|
||||||
borderBottom: activeTab === tab ? '2px solid #1976d2' : '2px solid transparent',
|
background: isActive ? '#fff' : 'transparent',
|
||||||
background: activeTab === tab ? '#fff' : 'transparent',
|
color: isActive ? '#1976d2' : '#666',
|
||||||
color: activeTab === tab ? '#1976d2' : '#666',
|
fontWeight: isActive ? 600 : 400,
|
||||||
fontWeight: activeTab === tab ? 600 : 400,
|
cursor: 'pointer', fontSize: '14px', whiteSpace: 'nowrap',
|
||||||
cursor: 'pointer',
|
transition: 'color 0.15s, background-color 0.15s',
|
||||||
fontSize: '13px',
|
display: 'flex', alignItems: 'center', gap: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<span
|
<span style={{
|
||||||
style={{
|
backgroundColor: isActive ? '#1976d2' : '#bbb',
|
||||||
marginLeft: '6px',
|
color: '#fff', borderRadius: '10px', padding: '1px 8px',
|
||||||
backgroundColor: activeTab === tab ? '#1976d2' : '#ccc',
|
fontSize: '11px', fontWeight: 600, lineHeight: '18px',
|
||||||
color: '#fff',
|
}}>{count}</span>
|
||||||
borderRadius: '10px',
|
|
||||||
padding: '1px 7px',
|
|
||||||
fontSize: '11px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -252,48 +262,34 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
|
<div style={{ flex: 1, overflow: 'auto', padding: '20px 28px' }}>
|
||||||
{activeTab === 'Opportunities' && (
|
{activeTab === 'Quick Wins' && <QuickWinsTab wins={quickWins} onSelect={onSelectSuggestion} />}
|
||||||
<OpportunitiesTab
|
{activeTab === 'Opportunities' && <OpportunitiesTab opportunities={contentOpportunities} onSelect={onSelectSuggestion} />}
|
||||||
opportunities={contentOpportunities}
|
{activeTab === 'Keyword Gaps' && <GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />}
|
||||||
onSelect={onSelectSuggestion}
|
{activeTab === 'Pages' && <PagesTab pages={pageOpportunities} />}
|
||||||
/>
|
{activeTab === 'AI Recommendations' && <AIRecommendationsTab recommendations={aiRecommendations} onSelect={onSelectSuggestion} />}
|
||||||
)}
|
|
||||||
{activeTab === 'Keyword Gaps' && (
|
|
||||||
<GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />
|
|
||||||
)}
|
|
||||||
{activeTab === 'AI Recommendations' && (
|
|
||||||
<AIRecommendationsTab
|
|
||||||
recommendations={aiRecommendations}
|
|
||||||
onSelect={onSelectSuggestion}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
padding: '14px 28px', borderTop: '1px solid #e8e8e8',
|
||||||
padding: '12px 24px',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
borderTop: '1px solid #e0e0e0',
|
backgroundColor: '#fafafa', flexShrink: 0,
|
||||||
display: 'flex',
|
}}>
|
||||||
justifyContent: 'flex-end',
|
<span style={{ fontSize: '12px', color: '#999' }}>Click any keyword or title to use it as your research topic</span>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 20px',
|
padding: '10px 24px', backgroundColor: '#fff',
|
||||||
backgroundColor: '#f5f5f5',
|
border: '1px solid #ddd', borderRadius: '8px',
|
||||||
border: '1px solid #ddd',
|
cursor: 'pointer', fontSize: '14px', color: '#555',
|
||||||
borderRadius: '6px',
|
transition: 'background-color 0.15s',
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
}}
|
}}
|
||||||
>
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
|
||||||
Close
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
|
||||||
</button>
|
>Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,196 +297,326 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Sub-components */
|
/* Summary Dashboard */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const OpportunitiesTab: React.FC<{
|
const SummaryDashboard: React.FC<{ summary: BrainstormSummary }> = ({ summary }) => {
|
||||||
opportunities: ContentOpportunity[];
|
const dist = summary.keyword_distribution || {};
|
||||||
onSelect: (keyword: string) => void;
|
const total = dist.positions_1_3 + dist.positions_4_10 + dist.positions_11_20 + dist.positions_21_plus || 1;
|
||||||
}> = ({ opportunities, onSelect }) => {
|
const healthColor = summary.health_score >= 70 ? '#2e7d32' : summary.health_score >= 40 ? '#f57c00' : '#d32f2f';
|
||||||
|
const ctrColor = summary.ctr_vs_benchmark >= 0 ? '#2e7d32' : '#d32f2f';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderBottom: '1px solid #e8e8e8', flexShrink: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: '28px', padding: '14px 28px',
|
||||||
|
backgroundColor: '#f8fbff', flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<MetricBox label="Impressions" value={summary.total_impressions?.toLocaleString()} />
|
||||||
|
<MetricBox label="Clicks" value={summary.total_clicks?.toLocaleString()} />
|
||||||
|
<MetricBox
|
||||||
|
label="Avg CTR"
|
||||||
|
value={`${summary.avg_ctr}%`}
|
||||||
|
sublabel={`vs 3.1% avg`}
|
||||||
|
sublabelColor={ctrColor}
|
||||||
|
driving
|
||||||
|
/>
|
||||||
|
<MetricBox label="Avg Position" value={`${summary.avg_position}`} />
|
||||||
|
<MetricBox label="SEO Health" value={`${summary.health_score}/100`} valueColor={healthColor} driving />
|
||||||
|
</div>
|
||||||
|
{total > 1 && (
|
||||||
|
<div style={{
|
||||||
|
padding: '0 28px 12px', display: 'flex', gap: '16px',
|
||||||
|
fontSize: '12px', color: '#666', flexWrap: 'wrap', alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '11px', fontWeight: 500, color: '#999', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||||
|
Rank Distribution
|
||||||
|
</span>
|
||||||
|
<DistBadge label="Top 3" count={dist.positions_1_3} total={total} color="#2e7d32" />
|
||||||
|
<DistBadge label="4-10" count={dist.positions_4_10} total={total} color="#1565c0" />
|
||||||
|
<DistBadge label="11-20" count={dist.positions_11_20} total={total} color="#f57c00" />
|
||||||
|
<DistBadge label="21+" count={dist.positions_21_plus} total={total} color="#999" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetricBox: React.FC<{
|
||||||
|
label: string; value: string; valueColor?: string;
|
||||||
|
sublabel?: string; sublabelColor?: string; driving?: boolean;
|
||||||
|
}> = ({ label, value, valueColor, sublabel, sublabelColor, driving }) => (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center', padding: driving ? '0 20px 0 0' : 0,
|
||||||
|
borderRight: driving ? '1px solid #e0e0e0' : 'none',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '20px', fontWeight: 700, color: valueColor || '#1a1a1a' }}>{value}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#888' }}>{label}</div>
|
||||||
|
{sublabel && <div style={{ fontSize: '10px', color: sublabelColor || '#999', fontWeight: 500 }}>{sublabel}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DistBadge: React.FC<{ label: string; count: number; total: number; color: string }> = ({ label, count, total, color }) => (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<span style={{
|
||||||
|
width: '10px', height: '10px', borderRadius: '50%',
|
||||||
|
backgroundColor: color, display: 'inline-block', flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
<span>{label}: <strong>{count}</strong> <span style={{ color: '#999' }}>({Math.round(count / total * 100)}%)</span></span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Quick Wins Tab */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const QuickWinsTab: React.FC<{ wins: QuickWin[]; onSelect: (kw: string) => void }> = ({ wins, onSelect }) => {
|
||||||
|
if (wins.length === 0) {
|
||||||
|
return <EmptyMessage message="No quick wins found. Your page-1 keywords may already be well-optimized." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
|
||||||
|
These keywords are already on page 1. A small optimization push could land them in the top 3 — the highest-ROI opportunities available.
|
||||||
|
</p>
|
||||||
|
{wins.map((win, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: '16px 18px', border: '1px solid #c8e6c9', borderRadius: '10px',
|
||||||
|
cursor: 'pointer', transition: 'all 0.15s', backgroundColor: '#f1f8e9',
|
||||||
|
borderLeft: '4px solid #4caf50',
|
||||||
|
}}
|
||||||
|
onClick={() => onSelect(win.keyword)}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#dcedc8'; e.currentTarget.style.borderLeftColor = '#2e7d32'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#f1f8e9'; e.currentTarget.style.borderLeftColor = '#4caf50'; }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
|
||||||
|
<span style={{ fontWeight: 600, fontSize: '15px', color: '#2e7d32' }}>{win.keyword}</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<Badge label={`#${Math.round(win.position)}`} color="#1565c0" />
|
||||||
|
<Badge label={`+${win.estimated_traffic_gain} clicks/mo`} color="#2e7d32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0 0 6px', fontSize: '13px', color: '#444', lineHeight: 1.5 }}>{win.reason}</p>
|
||||||
|
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||||
|
{win.impressions.toLocaleString()} impressions · {win.current_ctr}% current CTR
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Opportunities Tab */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const OpportunitiesTab: React.FC<{ opportunities: ContentOpportunity[]; onSelect: (kw: string) => void }> = ({ opportunities, onSelect }) => {
|
||||||
if (opportunities.length === 0) {
|
if (opportunities.length === 0) {
|
||||||
return <EmptyMessage message="No content opportunities found for this period." />;
|
return <EmptyMessage message="No content opportunities found for this period." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{opportunities.map((opp, i) => (
|
{opportunities.map((opp, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px',
|
padding: '16px 18px', border: '1px solid #e0e0e0', borderRadius: '10px',
|
||||||
border: '1px solid #e0e0e0',
|
cursor: 'pointer', transition: 'all 0.15s',
|
||||||
borderRadius: '8px',
|
borderLeft: `4px solid ${opp.priority === 'High' ? '#d32f2f' : '#f57c00'}`,
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'background-color 0.15s',
|
|
||||||
}}
|
}}
|
||||||
onClick={() => onSelect(opp.keyword)}
|
onClick={() => onSelect(opp.keyword)}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f7ff'}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
|
||||||
style={{
|
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{opp.keyword}</span>
|
||||||
display: 'flex',
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 600, fontSize: '14px', color: '#333' }}>
|
|
||||||
{opp.keyword}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: 'flex', gap: '6px' }}>
|
|
||||||
<Badge
|
<Badge
|
||||||
label={opp.type === 'Content Optimization' ? 'Optimize' : 'Enhance'}
|
label={opp.type === 'Content Optimization' ? 'Optimize' : 'Enhance'}
|
||||||
color={opp.type === 'Content Optimization' ? '#1565c0' : '#f57c00'}
|
color={opp.type === 'Content Optimization' ? '#1565c0' : '#f57c00'}
|
||||||
/>
|
/>
|
||||||
<Badge
|
<Badge label={opp.priority} color={opp.priority === 'High' ? '#d32f2f' : '#666'} />
|
||||||
label={opp.priority}
|
{opp.suggested_format && <Badge label={opp.suggested_format} color="#6a1b9a" />}
|
||||||
color={opp.priority === 'High' ? '#d32f2f' : '#666'}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ margin: '0 0 4px', fontSize: '13px', color: '#555' }}>
|
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#444', lineHeight: 1.5 }}>{opp.opportunity}</p>
|
||||||
{opp.opportunity}
|
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#888', flexWrap: 'wrap' }}>
|
||||||
</p>
|
<span>{opp.impressions.toLocaleString()} impressions</span>
|
||||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
<span>Position {opp.current_position}</span>
|
||||||
{opp.impressions.toLocaleString()} impressions · Position {opp.current_position}
|
<span>{opp.current_ctr}% CTR</span>
|
||||||
|
<span style={{ color: '#2e7d32', fontWeight: 600 }}>+{opp.estimated_traffic_gain} clicks/mo potential</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
|
|
||||||
Click any keyword to use it as your research topic.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const GapsTab: React.FC<{
|
/* ------------------------------------------------------------------ */
|
||||||
gaps: KeywordGap[];
|
/* Keyword Gaps Tab */
|
||||||
onSelect: (keyword: string) => void;
|
/* ------------------------------------------------------------------ */
|
||||||
}> = ({ gaps, onSelect }) => {
|
|
||||||
|
const GapsTab: React.FC<{ gaps: KeywordGap[]; onSelect: (kw: string) => void }> = ({ gaps, onSelect }) => {
|
||||||
if (gaps.length === 0) {
|
if (gaps.length === 0) {
|
||||||
return (
|
return <EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />;
|
||||||
<EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<p style={{ margin: '0 0 6px', fontSize: '14px', color: '#555' }}>
|
||||||
|
These keywords rank between positions 4-20. Writing targeted content could push them to page 1 where CTR increases dramatically.
|
||||||
|
</p>
|
||||||
{gaps.map((gap, i) => (
|
{gaps.map((gap, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px',
|
padding: '14px 16px', border: '1px solid #e0e0e0', borderRadius: '10px',
|
||||||
border: '1px solid #e0e0e0',
|
cursor: 'pointer', display: 'flex', justifyContent: 'space-between',
|
||||||
borderRadius: '8px',
|
alignItems: 'center', transition: 'background-color 0.15s',
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
transition: 'background-color 0.15s',
|
|
||||||
}}
|
}}
|
||||||
onClick={() => onSelect(gap.keyword)}
|
onClick={() => onSelect(gap.keyword)}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
|
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f7ff'}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
|
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 500, fontSize: '14px' }}>{gap.keyword}</span>
|
<div>
|
||||||
<div style={{ fontSize: '12px', color: '#999' }}>
|
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{gap.keyword}</span>
|
||||||
Position {gap.position} · {gap.impressions.toLocaleString()} impressions
|
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||||
|
{gap.current_ctr}% CTR · {gap.clicks} clicks
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right', fontSize: '12px' }}>
|
||||||
|
<div style={{ color: gap.position <= 10 ? '#1565c0' : '#f57c00', fontWeight: 600 }}>Position #{gap.position.toFixed(0)}</div>
|
||||||
|
<div style={{ color: '#2e7d32', fontWeight: 500 }}>+{gap.estimated_traffic_if_page1} clicks/mo if page 1</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
|
|
||||||
These keywords rank between positions 4-20. Writing targeted content could push them to page 1.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AIRecommendationsTab: React.FC<{
|
/* ------------------------------------------------------------------ */
|
||||||
recommendations: AIRecommendations | null;
|
/* Pages Tab */
|
||||||
onSelect: (keyword: string) => void;
|
/* ------------------------------------------------------------------ */
|
||||||
}> = ({ recommendations, onSelect }) => {
|
|
||||||
if (!recommendations) {
|
const PagesTab: React.FC<{ pages: PageOpportunity[] }> = ({ pages }) => {
|
||||||
return <EmptyMessage message="AI recommendations are not available right now." />;
|
if (pages.length === 0) {
|
||||||
|
return <EmptyMessage message="No page-level issues found. Your pages are performing well." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<RecommendationSection
|
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
|
||||||
title="Immediate Opportunities (0-30 days)"
|
These pages get significant impressions but low click-through rates. Improving their titles and meta descriptions can boost clicks.
|
||||||
items={recommendations.immediate_opportunities}
|
</p>
|
||||||
onSelect={onSelect}
|
{pages.map((pg, i) => (
|
||||||
color="#1565c0"
|
<div key={i} style={{
|
||||||
/>
|
padding: '16px 18px', border: '1px solid #e0e0e0', borderRadius: '10px',
|
||||||
<RecommendationSection
|
borderLeft: '4px solid #d32f2f',
|
||||||
title="Content Strategy (1-3 months)"
|
}}>
|
||||||
items={recommendations.content_strategy}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||||
onSelect={onSelect}
|
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{pg.page_title}</span>
|
||||||
color="#2e7d32"
|
<Badge label={`${pg.current_ctr}% CTR`} color={pg.current_ctr < 1 ? '#d32f2f' : '#f57c00'} />
|
||||||
/>
|
</div>
|
||||||
<RecommendationSection
|
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#555', lineHeight: 1.5 }}>{pg.reason}</p>
|
||||||
title="Long-Term Vision (3-12 months)"
|
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||||
items={recommendations.long_term_strategy}
|
{pg.impressions.toLocaleString()} impressions · {pg.clicks} clicks · Position {pg.current_position}
|
||||||
onSelect={onSelect}
|
</div>
|
||||||
color="#6a1b9a"
|
<div style={{ fontSize: '11px', color: '#999', marginTop: '6px', wordBreak: 'break-all' }}>{pg.page}</div>
|
||||||
/>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RecommendationSection: React.FC<{
|
/* ------------------------------------------------------------------ */
|
||||||
title: string;
|
/* AI Recommendations Tab */
|
||||||
items: string[];
|
/* ------------------------------------------------------------------ */
|
||||||
onSelect: (keyword: string) => void;
|
|
||||||
color: string;
|
const AIRecommendationsTab: React.FC<{ recommendations: AIRecommendations | null; onSelect: (kw: string) => void }> = ({ recommendations, onSelect }) => {
|
||||||
}> = ({ title, items, onSelect, color }) => {
|
if (!recommendations) {
|
||||||
|
return <EmptyMessage message="AI recommendations are not available right now. Try again in a moment." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<RecommendationSection title="Quick Wins (0-30 days)" items={recommendations.immediate_opportunities} onSelect={onSelect} color="#1565c0" />
|
||||||
|
<RecommendationSection title="Content Strategy (1-3 months)" items={recommendations.content_strategy} onSelect={onSelect} color="#2e7d32" />
|
||||||
|
<RecommendationSection title="Long-Term Vision (3-12 months)" items={recommendations.long_term_strategy} onSelect={onSelect} color="#6a1b9a" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecommendationSection: React.FC<{ title: string; items: AIRecommendation[]; onSelect: (kw: string) => void; color: string }> = ({ title, items, onSelect, color }) => {
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h4 style={{ margin: '0 0 8px', fontSize: '14px', color }}>{title}</h4>
|
<h4 style={{
|
||||||
<ul style={{ margin: 0, paddingLeft: '20px', listStyle: 'disc' }}>
|
margin: '0 0 12px', fontSize: '15px', color, fontWeight: 600,
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: '8px', height: '8px', borderRadius: '50%',
|
||||||
|
backgroundColor: color, display: 'inline-block',
|
||||||
|
}} />
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<li
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
style={{
|
style={{
|
||||||
fontSize: '13px',
|
padding: '14px 16px', border: '1px solid #e8e8e8', borderRadius: '10px',
|
||||||
color: '#444',
|
cursor: 'pointer', transition: 'all 0.15s',
|
||||||
marginBottom: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const short = item.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
|
const kw = item.keyword || item.title.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
|
||||||
if (short) onSelect(short);
|
if (kw && kw.length > 2) onSelect(kw);
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = '#f8faff'; e.currentTarget.style.borderColor = '#c8d8e8'; }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = '#fff'; e.currentTarget.style.borderColor = '#e8e8e8'; }}
|
||||||
>
|
>
|
||||||
{item}
|
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1a1a1a', marginBottom: '4px' }}>{item.title}</div>
|
||||||
</li>
|
{item.keyword && <div style={{ fontSize: '12px', color: '#888', marginBottom: '4px' }}>
|
||||||
|
Target: <strong style={{ color: '#555' }}>{item.keyword}</strong>
|
||||||
|
</div>}
|
||||||
|
{item.reason && <div style={{ fontSize: '13px', color: '#555', lineHeight: 1.5 }}>{item.reason}</div>}
|
||||||
|
<div style={{ display: 'flex', gap: '10px', marginTop: '8px' }}>
|
||||||
|
{item.format && <span style={{
|
||||||
|
fontSize: '11px', backgroundColor: '#f0f0f0',
|
||||||
|
padding: '2px 10px', borderRadius: '4px', color: '#666',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}>{item.format}</span>}
|
||||||
|
{item.estimated_impact && <span style={{
|
||||||
|
fontSize: '11px', color: '#2e7d32', fontWeight: 600,
|
||||||
|
}}>{item.estimated_impact}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Shared */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => (
|
const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => (
|
||||||
<span
|
<span style={{
|
||||||
style={{
|
fontSize: '11px', fontWeight: 600, padding: '3px 10px',
|
||||||
fontSize: '11px',
|
borderRadius: '5px', color: '#fff', backgroundColor: color,
|
||||||
fontWeight: 600,
|
whiteSpace: 'nowrap',
|
||||||
padding: '2px 8px',
|
}}>{label}</span>
|
||||||
borderRadius: '4px',
|
|
||||||
color: '#fff',
|
|
||||||
backgroundColor: color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const EmptyMessage: React.FC<{ message: string }> = ({ message }) => (
|
const EmptyMessage: React.FC<{ message: string }> = ({ message }) => (
|
||||||
<div style={{ padding: '32px 0', textAlign: 'center' }}>
|
<div style={{ padding: '48px 0', textAlign: 'center' }}>
|
||||||
<p style={{ color: '#888', margin: 0 }}>{message}</p>
|
<p style={{ color: '#888', margin: 0, fontSize: '14px' }}>{message}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { BrainstormButton } from './BrainstormButton';
|
|||||||
|
|
||||||
interface ManualResearchFormProps {
|
interface ManualResearchFormProps {
|
||||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||||
|
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete }) => {
|
export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResearchComplete, onBeforeResearchSubmit }) => {
|
||||||
const [keywords, setKeywords] = useState('');
|
const [keywords, setKeywords] = useState('');
|
||||||
const [blogLength, setBlogLength] = useState('1000');
|
const [blogLength, setBlogLength] = useState('1000');
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
await onBeforeResearchSubmit?.(trimmed, blogLength);
|
||||||
await startResearch(trimmed, blogLength);
|
await startResearch(trimmed, blogLength);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { forwardRef, useImperativeHandle } from 'react';
|
import React, { forwardRef, useImperativeHandle, useRef } 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';
|
||||||
import { blogWriterCache } from '../../services/blogWriterCache';
|
import { blogWriterCache } from '../../services/blogWriterCache';
|
||||||
@@ -22,6 +22,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
|||||||
navigateToPhase,
|
navigateToPhase,
|
||||||
onOutlineCreated
|
onOutlineCreated
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
|
// Guard against concurrent outline generation (multiple triggers: UI button + CopilotKit action)
|
||||||
|
const outlineGenInProgressRef = useRef(false);
|
||||||
|
|
||||||
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
generateNow: async () => {
|
generateNow: async () => {
|
||||||
@@ -29,13 +32,16 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
|||||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (outlineGenInProgressRef.current) {
|
||||||
|
return { success: false, message: 'Outline generation is already in progress.' };
|
||||||
|
}
|
||||||
|
|
||||||
// Check cache first (shared utility)
|
// Check cache first (shared utility)
|
||||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||||
|
|
||||||
if (cachedOutline) {
|
if (cachedOutline) {
|
||||||
console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length });
|
console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length });
|
||||||
// Return cached result - caller should handle setting outline state
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
cached: true,
|
cached: true,
|
||||||
@@ -44,6 +50,7 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outlineGenInProgressRef.current = true;
|
||||||
try {
|
try {
|
||||||
onModalShow?.();
|
onModalShow?.();
|
||||||
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
|
||||||
@@ -53,6 +60,8 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
return { success: false, message: errorMessage };
|
return { success: false, message: errorMessage };
|
||||||
|
} finally {
|
||||||
|
outlineGenInProgressRef.current = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -65,6 +74,10 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
|||||||
return { success: false, message: 'No research yet. Please research a topic first.' };
|
return { success: false, message: 'No research yet. Please research a topic first.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (outlineGenInProgressRef.current) {
|
||||||
|
return { success: false, message: 'Outline generation is already in progress. Please wait...' };
|
||||||
|
}
|
||||||
|
|
||||||
// Check cache first (shared utility)
|
// Check cache first (shared utility)
|
||||||
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
|
||||||
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
|
||||||
@@ -89,6 +102,7 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outlineGenInProgressRef.current = true;
|
||||||
try {
|
try {
|
||||||
// Navigate to outline phase when outline generation starts
|
// Navigate to outline phase when outline generation starts
|
||||||
navigateToPhase?.('outline');
|
navigateToPhase?.('outline');
|
||||||
@@ -129,6 +143,8 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
|||||||
success: false,
|
success: false,
|
||||||
message: userMessage
|
message: userMessage
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
outlineGenInProgressRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
render: ({ status }: any) => {
|
render: ({ status }: any) => {
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
|||||||
if (message.includes('All LLM providers failed') || message.includes('All configured LLM providers failed')) {
|
if (message.includes('All LLM providers failed') || message.includes('All configured LLM providers failed')) {
|
||||||
return '⚠️ All AI providers are currently unavailable. Please check your API keys or try again later.';
|
return '⚠️ All AI providers are currently unavailable. Please check your API keys or try again later.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Outline phase messages
|
||||||
if (message.includes('Starting outline generation')) {
|
if (message.includes('Starting outline generation')) {
|
||||||
return '🧩 Starting to create your blog outline...';
|
return '🧩 Starting to create your blog outline...';
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,28 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
|||||||
return '🎉 Success! Your personalized blog outline is ready!';
|
return '🎉 Success! Your personalized blog outline is ready!';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content generation phase messages
|
||||||
|
if (message.includes('Alwrity is preparing your blog content')) {
|
||||||
|
return '⏳ Alwrity is getting ready to write your blog — this usually takes 20–40 seconds. Your outline and research are being packaged for the AI.';
|
||||||
|
}
|
||||||
|
if (message.includes('Packaging your outline sections and research data')) {
|
||||||
|
return '📦 Organizing your outline sections, key points, and research data so the AI can write each section with full context.';
|
||||||
|
}
|
||||||
|
if (message.includes('Found existing content in cache')) {
|
||||||
|
return '⚡ Found previously generated content — loading it instantly so you don\'t have to wait!';
|
||||||
|
}
|
||||||
|
if (message.includes('AI is writing each section with research-backed insights')) {
|
||||||
|
return '🤖 AI is writing each section of your blog, weaving in research findings, key points, and maintaining a consistent voice throughout.';
|
||||||
|
}
|
||||||
|
if (message.includes('Polishing content')) {
|
||||||
|
return '✨ Reviewing and polishing your content — improving sentence flow, paragraph structure, and readability for a professional finish.';
|
||||||
|
}
|
||||||
|
if (message.includes('Content generation complete')) {
|
||||||
|
return message
|
||||||
|
.replace('Content generation complete!', '✅ Content generation complete!')
|
||||||
|
.replace('Next up:', '\n\n📌 Next phase:');
|
||||||
|
}
|
||||||
|
|
||||||
// Return the original message if no mapping found
|
// Return the original message if no mapping found
|
||||||
return message;
|
return message;
|
||||||
};
|
};
|
||||||
@@ -137,7 +161,9 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)'
|
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)'
|
||||||
}}>
|
}}>
|
||||||
{titleOverride || (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
|
{titleOverride
|
||||||
|
? (status === 'complete' ? '🎉 Content Ready!' : status === 'error' ? '❌ Generation Failed' : '📝 Generating Your Blog Content')
|
||||||
|
: (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
@@ -165,15 +191,15 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
|||||||
}}>
|
}}>
|
||||||
{titleOverride
|
{titleOverride
|
||||||
? (status === 'complete'
|
? (status === 'complete'
|
||||||
? 'Your AI-generated blog content is ready!'
|
? '✅ Your blog content has been generated! Review it in the editor, then optimize for SEO.'
|
||||||
: status === 'error'
|
: status === 'error'
|
||||||
? 'Something went wrong during generation'
|
? 'Content generation encountered an issue. You can retry from the content phase.'
|
||||||
: 'AI is generating your blog content...')
|
: 'Alwrity is writing your blog content using AI...')
|
||||||
: (status === 'complete'
|
: (status === 'complete'
|
||||||
? 'Your AI-powered blog outline is ready to use!'
|
? '✅ Your blog outline is ready! Review and confirm it, then proceed to generate content.'
|
||||||
: status === 'error'
|
: status === 'error'
|
||||||
? 'Something went wrong during outline generation'
|
? 'Outline generation encountered an issue. Please try again.'
|
||||||
: 'AI is analyzing your research and creating the perfect blog structure...')}
|
: 'Alwrity is analyzing your research and building your blog structure...')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,14 +214,21 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
|||||||
padding: '16px',
|
padding: '16px',
|
||||||
color: '#dc2626'
|
color: '#dc2626'
|
||||||
}}>
|
}}>
|
||||||
<strong>Error:</strong> {error}
|
<div style={{ fontWeight: '700', marginBottom: '4px' }}>❌ Error</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#991b1b', lineHeight: '1.5' }}>
|
||||||
|
{error.includes('You do not have access')
|
||||||
|
? 'You do not have access to the blog writer. Please check your subscription or account permissions.'
|
||||||
|
: error.includes('balance')
|
||||||
|
? 'Your API balance is insufficient. Please top up your account or switch to a different provider.'
|
||||||
|
: error}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Current Status */}
|
{/* Current Status */}
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#f0f9ff',
|
backgroundColor: status === 'complete' ? '#f0fdf4' : '#f0f9ff',
|
||||||
border: '1px solid #bae6fd',
|
border: `1px solid ${status === 'complete' ? '#bbf7d0' : '#bae6fd'}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
marginBottom: '20px'
|
marginBottom: '20px'
|
||||||
@@ -203,7 +236,7 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
|||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#0369a1',
|
color: status === 'complete' ? '#15803d' : '#0369a1',
|
||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -215,16 +248,17 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
|||||||
height: '8px',
|
height: '8px',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
backgroundColor: status === 'complete' ? '#10b981' : '#3b82f6',
|
backgroundColor: status === 'complete' ? '#10b981' : '#3b82f6',
|
||||||
animation: status === 'executing' ? 'pulse 2s infinite' : 'none'
|
animation: status === 'running' ? 'pulse 2s infinite' : 'none'
|
||||||
}} />
|
}} />
|
||||||
Current Status
|
{status === 'complete' ? 'Generation Complete' : 'Current Status'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
color: '#1e40af',
|
color: status === 'complete' ? '#166534' : '#1e40af',
|
||||||
lineHeight: '1.5'
|
lineHeight: '1.5',
|
||||||
|
whiteSpace: 'pre-wrap'
|
||||||
}}>
|
}}>
|
||||||
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing to generate your outline...'}
|
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing...'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -235,7 +269,7 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
|
|||||||
margin: '0 0 12px 0',
|
margin: '0 0 12px 0',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: '#374151'
|
color: '#374151'
|
||||||
}}>
|
}}>
|
||||||
Progress Timeline
|
Progress Timeline
|
||||||
</h4>
|
</h4>
|
||||||
|
|||||||
@@ -43,10 +43,20 @@ const PHASE_TOOLTIPS: Record<string, string> = {
|
|||||||
research: 'Research your topic and gather data from the web to create a well-informed blog post.',
|
research: 'Research your topic and gather data from the web to create a well-informed blog post.',
|
||||||
outline: 'Create and refine your blog outline with AI-generated structure and key talking points.',
|
outline: 'Create and refine your blog outline with AI-generated structure and key talking points.',
|
||||||
content: 'Generate, edit, and perfect your blog content using the WYSIWYG editor and AI assistance.',
|
content: 'Generate, edit, and perfect your blog content using the WYSIWYG editor and AI assistance.',
|
||||||
seo: 'Optimize your blog for search engines with AI-powered SEO analysis, recommendations, and metadata.',
|
seo: 'Optimize your blog for search engines with AI-powered analysis.',
|
||||||
publish: 'Publish your blog to WordPress, Wix, or export as HTML or Markdown.',
|
publish: 'Publish your blog to WordPress, Wix, or export as HTML or Markdown.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONTENT_TOOLTIPS: Record<string, string> = {
|
||||||
|
generate: 'Generate blog content from your confirmed outline.',
|
||||||
|
regenerate: 'Content exists. Click to review or regenerate content.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEO_TOOLTIPS: Record<string, string> = {
|
||||||
|
analyze: 'Run an AI-powered SEO analysis of your blog content. Checks keyword optimization, readability, content structure, and delivers actionable recommendations to improve search rankings. You can then apply recommendations and generate SEO metadata (title tags, meta descriptions, Open Graph tags).',
|
||||||
|
reanalyze: 'SEO analysis exists. Click to review results, re-analyze your content after edits, apply SEO recommendations to improve your content, or generate SEO metadata (title tags, meta descriptions, Open Graph tags) for better search visibility.',
|
||||||
|
};
|
||||||
|
|
||||||
const PHASE_ACTIONS: Record<string, string> = {
|
const PHASE_ACTIONS: Record<string, string> = {
|
||||||
research: 'Enter keywords to research your topic',
|
research: 'Enter keywords to research your topic',
|
||||||
outline: 'Create your blog outline to structure your content',
|
outline: 'Create your blog outline to structure your content',
|
||||||
@@ -91,19 +101,13 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'content':
|
case 'content':
|
||||||
if (hasOutline && !outlineConfirmed) {
|
if (hasOutline) {
|
||||||
return { label: 'Confirm & Generate Content', handler: actionHandlers.onContentAction || null };
|
return { label: hasContent ? 'Re-Content' : 'Generate Content', handler: actionHandlers.onContentAction || null };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'seo':
|
case 'seo':
|
||||||
if (hasContent && !hasSEOAnalysis) {
|
if (hasContent) {
|
||||||
return { label: 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
|
return { label: hasSEOAnalysis ? 'Re-Analyze SEO' : 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
|
||||||
}
|
|
||||||
if (hasSEOAnalysis && !seoRecommendationsApplied) {
|
|
||||||
return { label: 'Apply SEO Recommendations', handler: actionHandlers.onApplySEORecommendations || null };
|
|
||||||
}
|
|
||||||
if (hasSEOAnalysis && seoRecommendationsApplied && !hasSEOMetadata) {
|
|
||||||
return { label: 'Generate SEO Metadata', handler: actionHandlers.onPublishAction || null };
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'publish':
|
case 'publish':
|
||||||
@@ -202,8 +206,9 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
|||||||
|
|
||||||
/* Show action button only when phase is NOT completed.
|
/* Show action button only when phase is NOT completed.
|
||||||
Research action: only on landing page (not current), to invite start.
|
Research action: only on landing page (not current), to invite start.
|
||||||
Other phase actions: show when current, pending, or next-actionable. */
|
Other phase actions: show when current, pending, or next-actionable.
|
||||||
const showAction = action.handler && !isDone && (
|
Content and SEO phases use only the chip (no separate action button). */
|
||||||
|
const showAction = action.handler && !isDone && phase.id !== 'content' && phase.id !== 'seo' && (
|
||||||
(!isCurrent && phase.id === 'research' && !hasResearch) ||
|
(!isCurrent && phase.id === 'research' && !hasResearch) ||
|
||||||
(isCurrent && phase.id !== 'research') ||
|
(isCurrent && phase.id !== 'research') ||
|
||||||
(!isCurrent && !isDisabled && phase.id !== 'research') ||
|
(!isCurrent && !isDisabled && phase.id !== 'research') ||
|
||||||
@@ -328,11 +333,17 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
title={
|
title={
|
||||||
<Box>
|
<Box>
|
||||||
<Box sx={{ fontWeight: 700, mb: 0.5, fontSize: '0.875rem' }}>{phase.name}</Box>
|
<Box sx={{ fontWeight: 700, mb: 0.5, fontSize: '0.875rem' }}>
|
||||||
|
{phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}
|
||||||
|
</Box>
|
||||||
<Box sx={{ fontSize: '0.75rem', opacity: 0.9 }}>
|
<Box sx={{ fontSize: '0.75rem', opacity: 0.9 }}>
|
||||||
{isDisabled
|
{isDisabled
|
||||||
? `Complete the previous phase first to unlock ${phase.name}.`
|
? `Complete the previous phase first to unlock ${phase.name}.`
|
||||||
: (PHASE_TOOLTIPS[phase.id] || phase.description)}
|
: (phase.id === 'content'
|
||||||
|
? (hasContent ? CONTENT_TOOLTIPS.regenerate : CONTENT_TOOLTIPS.generate)
|
||||||
|
: (phase.id === 'seo'
|
||||||
|
? (hasSEOAnalysis ? SEO_TOOLTIPS.reanalyze : SEO_TOOLTIPS.analyze)
|
||||||
|
: (PHASE_TOOLTIPS[phase.id] || phase.description)))}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
}
|
||||||
@@ -347,7 +358,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
|||||||
sx={chipSx}
|
sx={chipSx}
|
||||||
>
|
>
|
||||||
<Box component="span" sx={iconSx}>{phase.icon}</Box>
|
<Box component="span" sx={iconSx}>{phase.icon}</Box>
|
||||||
<Box component="span" sx={{ flexShrink: 0 }}>{phase.name}</Box>
|
<Box component="span" sx={{ flexShrink: 0 }}>{phase.id === 'content' && hasContent ? 'Re-Content' : phase.id === 'seo' ? (hasSEOAnalysis ? 'Re-Analyze SEO' : 'SEO Analysis') : phase.name}</Box>
|
||||||
{isDone && (
|
{isDone && (
|
||||||
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}>✓</Box>
|
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}>✓</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface PublisherProps {
|
|||||||
buildFullMarkdown: () => string;
|
buildFullMarkdown: () => string;
|
||||||
convertMarkdownToHTML: (md: string) => string;
|
convertMarkdownToHTML: (md: string) => string;
|
||||||
seoMetadata: BlogSEOMetadataResponse | null;
|
seoMetadata: BlogSEOMetadataResponse | null;
|
||||||
|
onPublishComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveCompleteBlogAsset = async (
|
const saveCompleteBlogAsset = async (
|
||||||
@@ -37,7 +38,8 @@ const useCopilotActionTyped = useCopilotAction as any;
|
|||||||
export const Publisher: React.FC<PublisherProps> = ({
|
export const Publisher: React.FC<PublisherProps> = ({
|
||||||
buildFullMarkdown,
|
buildFullMarkdown,
|
||||||
convertMarkdownToHTML,
|
convertMarkdownToHTML,
|
||||||
seoMetadata
|
seoMetadata,
|
||||||
|
onPublishComplete,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
publishToWix,
|
publishToWix,
|
||||||
@@ -87,6 +89,7 @@ export const Publisher: React.FC<PublisherProps> = ({
|
|||||||
md,
|
md,
|
||||||
seoMetadata
|
seoMetadata
|
||||||
);
|
);
|
||||||
|
onPublishComplete?.();
|
||||||
}
|
}
|
||||||
return wixResult;
|
return wixResult;
|
||||||
} else if (platform === 'wordpress') {
|
} else if (platform === 'wordpress') {
|
||||||
@@ -137,6 +140,7 @@ export const Publisher: React.FC<PublisherProps> = ({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
saveCompleteBlogAsset(title, md, seoMetadata);
|
saveCompleteBlogAsset(title, md, seoMetadata);
|
||||||
|
onPublishComplete?.();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`,
|
url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`,
|
||||||
|
|||||||
@@ -241,29 +241,41 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
|||||||
console.log('🔄 Force refresh requested, skipping cache check');
|
console.log('🔄 Force refresh requested, skipping cache check');
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgressMessage('Starting SEO analysis...');
|
// Backend call — run concurrently with progress simulation
|
||||||
|
// Use longer timeout (120s) since SEO analysis can take 40-60s
|
||||||
// Simulated progress
|
const responsePromise = apiClient.post('/api/blog-writer/seo/analyze', {
|
||||||
const progressStages = [
|
|
||||||
{ progress: 20, message: 'Extracting keywords from research data...' },
|
|
||||||
{ progress: 40, message: 'Analyzing content structure and readability...' },
|
|
||||||
{ progress: 70, message: 'Generating AI-powered insights...' },
|
|
||||||
{ progress: 90, message: 'Compiling analysis results...' },
|
|
||||||
{ progress: 100, message: 'SEO analysis completed!' }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const stage of progressStages) {
|
|
||||||
setProgress(stage.progress);
|
|
||||||
setProgressMessage(stage.message);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backend call
|
|
||||||
const response = await apiClient.post('/api/blog-writer/seo/analyze', {
|
|
||||||
blog_content: blogContent,
|
blog_content: blogContent,
|
||||||
blog_title: blogTitle,
|
blog_title: blogTitle,
|
||||||
research_data: researchData
|
research_data: researchData
|
||||||
});
|
}, { timeout: 120000 });
|
||||||
|
|
||||||
|
// Simulated progress runs alongside the API call to keep the user informed.
|
||||||
|
// Each stage.at is cumulative ms from start. Cancelled when the API returns.
|
||||||
|
let progressCancelled = false;
|
||||||
|
const progressStages = [
|
||||||
|
{ at: 2000, progress: 10, message: 'Extracting keywords from research data...' },
|
||||||
|
{ at: 8000, progress: 25, message: 'Analyzing content structure and readability...' },
|
||||||
|
{ at: 20000, progress: 40, message: 'Evaluating heading hierarchy and flow...' },
|
||||||
|
{ at: 35000, progress: 55, message: 'Checking keyword density and optimization...' },
|
||||||
|
{ at: 50000, progress: 70, message: 'Generating AI-powered SEO insights...' },
|
||||||
|
{ at: 65000, progress: 85, message: 'Compiling analysis results and recommendations...' },
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
for (const stage of progressStages) {
|
||||||
|
if (progressCancelled) return;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const wait = Math.max(0, stage.at - elapsed);
|
||||||
|
if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait));
|
||||||
|
if (progressCancelled) return;
|
||||||
|
setProgress(stage.progress);
|
||||||
|
setProgressMessage(stage.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const response = await responsePromise;
|
||||||
|
progressCancelled = true;
|
||||||
|
|
||||||
const result = response.data;
|
const result = response.data;
|
||||||
console.log('🔍 Backend SEO Analysis Response:', result);
|
console.log('🔍 Backend SEO Analysis Response:', result);
|
||||||
|
|||||||
@@ -107,8 +107,9 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
|||||||
|
|
||||||
const handleContentChange = (e: any) => {
|
const handleContentChange = (e: any) => {
|
||||||
const newContent = e.target.value;
|
const newContent = e.target.value;
|
||||||
|
const cursorPos = e.target.selectionStart;
|
||||||
setContent(newContent);
|
setContent(newContent);
|
||||||
assistiveWriting.handleTypingChange(newContent);
|
assistiveWriting.handleTypingChange(newContent, cursorPos);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => setIsFocused(true);
|
const handleFocus = () => setIsFocused(true);
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const useBlogTextSelectionHandler = (
|
|||||||
}
|
}
|
||||||
}, 2000); // Update every 2 seconds
|
}, 2000); // Update every 2 seconds
|
||||||
|
|
||||||
// Set a timeout for the fact check (30 seconds)
|
// Set a timeout for the fact check (120 seconds)
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
console.log('🔍 [BlogTextSelectionHandler] Fact check timeout reached');
|
console.log('🔍 [BlogTextSelectionHandler] Fact check timeout reached');
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
@@ -76,9 +76,9 @@ const useBlogTextSelectionHandler = (
|
|||||||
refuted_claims: 0,
|
refuted_claims: 0,
|
||||||
insufficient_claims: 0,
|
insufficient_claims: 0,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
error: 'Fact check timed out after 30 seconds. Please try again with shorter text.'
|
error: 'Fact check timed out after 120 seconds. Please try again with shorter text.'
|
||||||
});
|
});
|
||||||
}, 30000); // 30 second timeout
|
}, 120000); // 120 second timeout
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('🔍 [BlogTextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
|
console.log('🔍 [BlogTextSelectionHandler] Calling hallucinationDetectorService.detectHallucinations...');
|
||||||
@@ -219,6 +219,27 @@ const useBlogTextSelectionHandler = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Close selection menu when clicking outside any selection menu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectionMenu) return;
|
||||||
|
|
||||||
|
const handleGlobalClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest('[data-selection-menu]') && !target.closest('[data-fact-check-results]')) {
|
||||||
|
setSelectionMenu(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.addEventListener('mousedown', handleGlobalClick);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('mousedown', handleGlobalClick);
|
||||||
|
};
|
||||||
|
}, [selectionMenu]);
|
||||||
|
|
||||||
// Cleanup progress and timeouts on unmount
|
// Cleanup progress and timeouts on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { debug } from '../../../utils/debug';
|
import { debug } from '../../../utils/debug';
|
||||||
|
import { assistiveWritingApi } from '../../../services/blogWriterApi';
|
||||||
|
|
||||||
interface SmartTypingAssistProps {
|
interface SmartTypingAssistProps {
|
||||||
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
|
||||||
@@ -47,6 +48,8 @@ const useSmartTypingAssist = (
|
|||||||
const hasShownFirstRef = useRef(false);
|
const hasShownFirstRef = useRef(false);
|
||||||
const isGeneratingRef = useRef(false);
|
const isGeneratingRef = useRef(false);
|
||||||
const smartSuggestionRef = useRef<typeof smartSuggestion>(null);
|
const smartSuggestionRef = useRef<typeof smartSuggestion>(null);
|
||||||
|
const initialContentLengthRef = useRef<number | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
// Quality improvement tracking
|
// Quality improvement tracking
|
||||||
const [suggestionStats, setSuggestionStats] = useState({
|
const [suggestionStats, setSuggestionStats] = useState({
|
||||||
@@ -57,8 +60,8 @@ const useSmartTypingAssist = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Smart typing assist functionality
|
// Smart typing assist functionality
|
||||||
const generateSmartSuggestion = async (currentText: string) => {
|
const generateSmartSuggestion = async (currentText: string, cursorPosition?: number) => {
|
||||||
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length });
|
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length, cursorPosition });
|
||||||
|
|
||||||
if (currentText.length < 20) {
|
if (currentText.length < 20) {
|
||||||
debug.log('[SmartTypingAssist] Text too short for suggestion');
|
debug.log('[SmartTypingAssist] Text too short for suggestion');
|
||||||
@@ -70,57 +73,61 @@ const useSmartTypingAssist = (
|
|||||||
isGeneratingRef.current = true;
|
isGeneratingRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Import the assistive writing API
|
if (!mountedRef.current) return;
|
||||||
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
|
|
||||||
|
|
||||||
debug.log('[SmartTypingAssist] Calling assistive writing API...');
|
debug.log('[SmartTypingAssist] Calling assistive writing API...');
|
||||||
const response = await assistiveWritingApi.getSuggestion(currentText);
|
const response = await assistiveWritingApi.getSuggestion(currentText, cursorPosition);
|
||||||
|
|
||||||
if (response.success && response.suggestions.length > 0) {
|
if (!mountedRef.current) return;
|
||||||
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
|
|
||||||
|
if (!response.success || !response.suggestions.length) {
|
||||||
|
debug.log('[SmartTypingAssist] No suggestions from API', { message: response.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
|
||||||
|
|
||||||
|
// Store all suggestions
|
||||||
|
setAllSuggestions(response.suggestions);
|
||||||
|
setSuggestionIndex(0);
|
||||||
|
|
||||||
|
// Show first suggestion
|
||||||
|
const firstSuggestion = response.suggestions[0];
|
||||||
|
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
|
||||||
|
|
||||||
|
// Track suggestion shown
|
||||||
|
setSuggestionStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
totalShown: prev.totalShown + 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Get viewport-safe position for suggestion placement
|
||||||
|
if (contentRef.current) {
|
||||||
|
const element = contentRef.current;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const maxWidth = 420;
|
||||||
|
const maxHeight = 350;
|
||||||
|
|
||||||
// Store all suggestions
|
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
||||||
setAllSuggestions(response.suggestions);
|
let y = rect.bottom + 10;
|
||||||
setSuggestionIndex(0);
|
|
||||||
|
|
||||||
// Show first suggestion
|
if (y + maxHeight > window.innerHeight - 20) {
|
||||||
const firstSuggestion = response.suggestions[0];
|
y = rect.top - maxHeight - 10;
|
||||||
debug.log('[SmartTypingAssist] Showing first suggestion', { preview: firstSuggestion.text.substring(0, 50) + '...' });
|
if (y < 20) {
|
||||||
|
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
||||||
// Track suggestion shown
|
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
||||||
setSuggestionStats(prev => ({
|
|
||||||
...prev,
|
|
||||||
totalShown: prev.totalShown + 1
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Get viewport-safe position for suggestion placement
|
|
||||||
if (contentRef.current) {
|
|
||||||
const element = contentRef.current;
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const maxWidth = 420;
|
|
||||||
const maxHeight = 350;
|
|
||||||
|
|
||||||
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
|
|
||||||
let y = rect.bottom + 10;
|
|
||||||
|
|
||||||
if (y + maxHeight > window.innerHeight - 20) {
|
|
||||||
y = rect.top - maxHeight - 10;
|
|
||||||
if (y < 20) {
|
|
||||||
y = Math.max(20, (window.innerHeight - maxHeight) / 2);
|
|
||||||
x = Math.max(20, (window.innerWidth - maxWidth) / 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
|
||||||
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
|
||||||
|
|
||||||
setSmartSuggestion({
|
|
||||||
text: firstSuggestion.text,
|
|
||||||
position: { x, y },
|
|
||||||
confidence: firstSuggestion.confidence,
|
|
||||||
sources: firstSuggestion.sources
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
y = Math.max(20, Math.min(y, window.innerHeight - maxHeight - 20));
|
||||||
|
x = Math.max(20, Math.min(x, window.innerWidth - maxWidth - 20));
|
||||||
|
|
||||||
|
setSmartSuggestion({
|
||||||
|
text: firstSuggestion.text,
|
||||||
|
position: { x, y },
|
||||||
|
confidence: firstSuggestion.confidence,
|
||||||
|
sources: firstSuggestion.sources
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
|
debug.error('[SmartTypingAssist] Failed to generate smart suggestion', error);
|
||||||
@@ -130,24 +137,34 @@ const useSmartTypingAssist = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTypingChange = (newText: string) => {
|
const handleTypingChange = (newText: string, cursorPosition?: number) => {
|
||||||
if (typingTimeoutRef.current) {
|
if (typingTimeoutRef.current) {
|
||||||
clearTimeout(typingTimeoutRef.current);
|
clearTimeout(typingTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSmartSuggestion(null);
|
// Track initial content baseline on first user keystroke
|
||||||
|
// This prevents triggering suggestions on pre-filled content
|
||||||
|
if (initialContentLengthRef.current === null) {
|
||||||
|
initialContentLengthRef.current = newText.length;
|
||||||
|
debug.log('[SmartTypingAssist] Set initial content baseline', { length: newText.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store cursor position for use after debounce
|
||||||
|
const cursorPos = cursorPosition;
|
||||||
|
|
||||||
typingTimeoutRef.current = setTimeout(() => {
|
typingTimeoutRef.current = setTimeout(() => {
|
||||||
const cooldownMs = 15000;
|
const cooldownMs = 15000;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const sinceLast = now - lastGeneratedAtRef.current;
|
const sinceLast = now - lastGeneratedAtRef.current;
|
||||||
|
const baseline = initialContentLengthRef.current ?? 0;
|
||||||
|
const userAddedChars = newText.length - baseline;
|
||||||
|
|
||||||
if (!hasShownFirstRef.current && newText.length > 50 && !isGeneratingRef.current) {
|
if (!hasShownFirstRef.current && newText.length >= 50 && userAddedChars >= 30 && !isGeneratingRef.current) {
|
||||||
debug.log('[SmartTypingAssist] Generating first suggestion');
|
debug.log('[SmartTypingAssist] Generating first suggestion');
|
||||||
generateSmartSuggestion(newText);
|
generateSmartSuggestion(newText, cursorPos);
|
||||||
setHasShownFirstSuggestion(true);
|
setHasShownFirstSuggestion(true);
|
||||||
lastGeneratedAtRef.current = now;
|
lastGeneratedAtRef.current = now;
|
||||||
} else if (hasShownFirstRef.current && newText.length > 100 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) {
|
} else if (hasShownFirstRef.current && newText.length > 100 && userAddedChars >= 30 && sinceLast >= cooldownMs && !isGeneratingRef.current && !smartSuggestionRef.current) {
|
||||||
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
|
debug.log('[SmartTypingAssist] Showing "Continue writing" prompt');
|
||||||
setShowContinueWritingPrompt(true);
|
setShowContinueWritingPrompt(true);
|
||||||
}
|
}
|
||||||
@@ -241,11 +258,14 @@ const useSmartTypingAssist = (
|
|||||||
|
|
||||||
const element = contentRef.current as HTMLTextAreaElement;
|
const element = contentRef.current as HTMLTextAreaElement;
|
||||||
const currentContent = element.value || '';
|
const currentContent = element.value || '';
|
||||||
|
const cursorPos = element.selectionStart;
|
||||||
|
|
||||||
setShowContinueWritingPrompt(false);
|
setShowContinueWritingPrompt(false);
|
||||||
|
|
||||||
if (currentContent.length > 20) {
|
const baseline = initialContentLengthRef.current ?? 0;
|
||||||
await generateSmartSuggestion(currentContent);
|
const userAddedChars = currentContent.length - baseline;
|
||||||
|
if (currentContent.length > 20 && userAddedChars >= 10) {
|
||||||
|
await generateSmartSuggestion(currentContent, cursorPos);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,9 +294,11 @@ const useSmartTypingAssist = (
|
|||||||
useEffect(() => { isGeneratingRef.current = isGeneratingSuggestion; }, [isGeneratingSuggestion]);
|
useEffect(() => { isGeneratingRef.current = isGeneratingSuggestion; }, [isGeneratingSuggestion]);
|
||||||
useEffect(() => { smartSuggestionRef.current = smartSuggestion; }, [smartSuggestion]);
|
useEffect(() => { smartSuggestionRef.current = smartSuggestion; }, [smartSuggestion]);
|
||||||
|
|
||||||
// Cleanup timeouts on unmount
|
// Mount guard and cleanup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
return () => {
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
if (typingTimeoutRef.current) {
|
if (typingTimeoutRef.current) {
|
||||||
clearTimeout(typingTimeoutRef.current);
|
clearTimeout(typingTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
|||||||
{/* Text Selection Menu */}
|
{/* Text Selection Menu */}
|
||||||
{selectionMenu && (
|
{selectionMenu && (
|
||||||
<div
|
<div
|
||||||
|
data-selection-menu="true"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
console.log('🔍 [TextSelectionMenu] Selection menu clicked!', e.target);
|
console.log('🔍 [TextSelectionMenu] Selection menu clicked!', e.target);
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -497,6 +498,27 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
|
|||||||
}}>
|
}}>
|
||||||
"{smartSuggestion.text}"
|
"{smartSuggestion.text}"
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{smartSuggestion.sources && smartSuggestion.sources.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
marginBottom: '12px',
|
||||||
|
borderTop: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
paddingTop: '10px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '11px', fontWeight: 600, opacity: 0.8, marginBottom: '6px' }}>
|
||||||
|
Sources:
|
||||||
|
</div>
|
||||||
|
{smartSuggestion.sources.slice(0, 2).map((src, i) => (
|
||||||
|
<div key={i} style={{ fontSize: '11px', opacity: 0.85, marginBottom: '4px', lineHeight: '1.3' }}>
|
||||||
|
<a href={src.url} target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ color: 'white', textDecoration: 'underline' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
{src.title || src.url}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -280,6 +280,10 @@ export const AssetLibrary: React.FC = () => {
|
|||||||
setAnchorEl({ ...anchorEl, [assetId]: null });
|
setAnchorEl({ ...anchorEl, [assetId]: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenBlogAsset = async (asset: ContentAsset) => {
|
||||||
|
navigate('/blog-writer', { state: { restoreBlogAssetId: asset.id } });
|
||||||
|
};
|
||||||
|
|
||||||
const handleRestoreResearchProject = async (asset: ContentAsset) => {
|
const handleRestoreResearchProject = async (asset: ContentAsset) => {
|
||||||
try {
|
try {
|
||||||
const projectId = asset.asset_metadata?.project_id;
|
const projectId = asset.asset_metadata?.project_id;
|
||||||
@@ -685,6 +689,7 @@ export const AssetLibrary: React.FC = () => {
|
|||||||
onShare={handleShare}
|
onShare={handleShare}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onRestore={handleRestoreResearchProject}
|
onRestore={handleRestoreResearchProject}
|
||||||
|
onOpenBlogAsset={handleOpenBlogAsset}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface AssetCardProps {
|
|||||||
onShare: (asset: ContentAsset) => void;
|
onShare: (asset: ContentAsset) => void;
|
||||||
onDelete: (id: number) => void;
|
onDelete: (id: number) => void;
|
||||||
onRestore: (asset: ContentAsset) => void;
|
onRestore: (asset: ContentAsset) => void;
|
||||||
|
onOpenBlogAsset?: (asset: ContentAsset) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AssetCard: React.FC<AssetCardProps> = ({
|
export const AssetCard: React.FC<AssetCardProps> = ({
|
||||||
@@ -44,6 +45,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
onShare,
|
onShare,
|
||||||
onDelete,
|
onDelete,
|
||||||
onRestore,
|
onRestore,
|
||||||
|
onOpenBlogAsset,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -232,6 +234,18 @@ export const AssetCard: React.FC<AssetCardProps> = ({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{/* Open Blog Asset button for blog_writer text assets */}
|
||||||
|
{asset.source_module === 'blog_writer' && asset.asset_type === 'text' && onOpenBlogAsset && (
|
||||||
|
<Tooltip title="Open in Blog Writer">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onOpenBlogAsset(asset)}
|
||||||
|
sx={{ color: '#3b82f6' }}
|
||||||
|
>
|
||||||
|
<Box sx={{ fontSize: 20 }}>✏️</Box>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => onDownload(asset)}
|
onClick={() => onDownload(asset)}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useAuth } from '@clerk/clerk-react';
|
import { useAuth } from '@clerk/clerk-react';
|
||||||
import {
|
import {
|
||||||
gscBrainstormAPI,
|
gscBrainstormAPI,
|
||||||
BrainstormResult,
|
BrainstormResult,
|
||||||
ContentOpportunity,
|
ContentOpportunity,
|
||||||
KeywordGap,
|
KeywordGap,
|
||||||
|
QuickWin,
|
||||||
|
PageOpportunity,
|
||||||
AIRecommendations,
|
AIRecommendations,
|
||||||
|
AIRecommendation,
|
||||||
BrainstormSummary,
|
BrainstormSummary,
|
||||||
} from '../api/gscBrainstorm';
|
} from '../api/gscBrainstorm';
|
||||||
import { useGSCBrainstormConnection } from './useGSCBrainstormConnection';
|
import { useGSCBrainstormConnection } from './useGSCBrainstormConnection';
|
||||||
@@ -20,13 +23,27 @@ interface UseGSCBrainstormReturn {
|
|||||||
brainstormResult: BrainstormResult | null;
|
brainstormResult: BrainstormResult | null;
|
||||||
contentOpportunities: ContentOpportunity[];
|
contentOpportunities: ContentOpportunity[];
|
||||||
keywordGaps: KeywordGap[];
|
keywordGaps: KeywordGap[];
|
||||||
|
quickWins: QuickWin[];
|
||||||
|
pageOpportunities: PageOpportunity[];
|
||||||
aiRecommendations: AIRecommendations | null;
|
aiRecommendations: AIRecommendations | null;
|
||||||
summary: BrainstormSummary | null;
|
summary: BrainstormSummary | null;
|
||||||
connectGSC: () => Promise<void>;
|
connectGSC: () => Promise<void>;
|
||||||
brainstorm: (keywords: string, siteUrl?: string) => Promise<BrainstormResult | null>;
|
brainstorm: (keywords: string, siteUrl?: string) => Promise<BrainstormResult | null>;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
progressMessage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PROGRESS_MESSAGES = [
|
||||||
|
'Fetching your Google Search Console data for the last 30 days...',
|
||||||
|
'Analyzing which keywords bring traffic to your site and which ones need work...',
|
||||||
|
'Scanning for quick wins — keywords already on page 1 that just need a boost...',
|
||||||
|
'Identifying keyword gaps where better content could move you to page 1...',
|
||||||
|
'Reviewing your pages for optimization opportunities...',
|
||||||
|
'Computing your SEO health score and benchmark metrics...',
|
||||||
|
'Generating AI-powered blog post recommendations tailored to your GSC data...',
|
||||||
|
'Formatting insights into actionable topic suggestions you can use today...',
|
||||||
|
];
|
||||||
|
|
||||||
export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
||||||
const { getToken } = useAuth();
|
const { getToken } = useAuth();
|
||||||
const {
|
const {
|
||||||
@@ -41,11 +58,45 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
|||||||
const [isBrainstorming, setIsBrainstorming] = useState(false);
|
const [isBrainstorming, setIsBrainstorming] = useState(false);
|
||||||
const [brainstormError, setBrainstormError] = useState<string | null>(null);
|
const [brainstormError, setBrainstormError] = useState<string | null>(null);
|
||||||
const [brainstormResult, setBrainstormResult] = useState<BrainstormResult | null>(null);
|
const [brainstormResult, setBrainstormResult] = useState<BrainstormResult | null>(null);
|
||||||
|
const [progressMessage, setProgressMessage] = useState('');
|
||||||
|
const progressIndexRef = useRef(0);
|
||||||
|
const progressTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (progressTimerRef.current) {
|
||||||
|
clearInterval(progressTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startProgressMessages = () => {
|
||||||
|
progressIndexRef.current = 0;
|
||||||
|
setProgressMessage(PROGRESS_MESSAGES[0]);
|
||||||
|
progressTimerRef.current = setInterval(() => {
|
||||||
|
progressIndexRef.current += 1;
|
||||||
|
if (progressIndexRef.current < PROGRESS_MESSAGES.length) {
|
||||||
|
setProgressMessage(PROGRESS_MESSAGES[progressIndexRef.current]);
|
||||||
|
} else if (progressTimerRef.current) {
|
||||||
|
clearInterval(progressTimerRef.current);
|
||||||
|
progressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopProgressMessages = () => {
|
||||||
|
if (progressTimerRef.current) {
|
||||||
|
clearInterval(progressTimerRef.current);
|
||||||
|
progressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setProgressMessage('');
|
||||||
|
};
|
||||||
|
|
||||||
const brainstorm = useCallback(
|
const brainstorm = useCallback(
|
||||||
async (keywords: string, siteUrl?: string): Promise<BrainstormResult | null> => {
|
async (keywords: string, siteUrl?: string): Promise<BrainstormResult | null> => {
|
||||||
setIsBrainstorming(true);
|
setIsBrainstorming(true);
|
||||||
setBrainstormError(null);
|
setBrainstormError(null);
|
||||||
|
startProgressMessages();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
gscBrainstormAPI.setAuthTokenGetter(async () => {
|
gscBrainstormAPI.setAuthTokenGetter(async () => {
|
||||||
@@ -66,6 +117,7 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
|||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setIsBrainstorming(false);
|
setIsBrainstorming(false);
|
||||||
|
stopProgressMessages();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getToken],
|
[getToken],
|
||||||
@@ -75,6 +127,7 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
|||||||
setBrainstormResult(null);
|
setBrainstormResult(null);
|
||||||
setBrainstormError(null);
|
setBrainstormError(null);
|
||||||
setIsBrainstorming(false);
|
setIsBrainstorming(false);
|
||||||
|
stopProgressMessages();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -87,16 +140,19 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
|
|||||||
brainstormResult,
|
brainstormResult,
|
||||||
contentOpportunities: brainstormResult?.content_opportunities ?? [],
|
contentOpportunities: brainstormResult?.content_opportunities ?? [],
|
||||||
keywordGaps: brainstormResult?.keyword_gaps ?? [],
|
keywordGaps: brainstormResult?.keyword_gaps ?? [],
|
||||||
|
quickWins: brainstormResult?.quick_wins ?? [],
|
||||||
|
pageOpportunities: brainstormResult?.page_opportunities ?? [],
|
||||||
aiRecommendations: brainstormResult?.ai_recommendations
|
aiRecommendations: brainstormResult?.ai_recommendations
|
||||||
&& Object.keys(brainstormResult.ai_recommendations).length > 0
|
&& Array.isArray(brainstormResult.ai_recommendations?.immediate_opportunities)
|
||||||
? (brainstormResult.ai_recommendations as AIRecommendations)
|
? (brainstormResult.ai_recommendations as AIRecommendations)
|
||||||
: null,
|
: null,
|
||||||
summary: brainstormResult?.summary
|
summary: brainstormResult?.summary
|
||||||
&& Object.keys(brainstormResult.summary).length > 0
|
&& brainstormResult.summary.site_url
|
||||||
? (brainstormResult.summary as BrainstormSummary)
|
? (brainstormResult.summary as BrainstormSummary)
|
||||||
: null,
|
: null,
|
||||||
connectGSC,
|
connectGSC,
|
||||||
brainstorm,
|
brainstorm,
|
||||||
reset,
|
reset,
|
||||||
|
progressMessage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,54 +92,84 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn =
|
|||||||
}
|
}
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
let messageHandled = false;
|
let resolved = false;
|
||||||
|
|
||||||
|
const finish = (connected: boolean) => {
|
||||||
|
if (resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
clearTimeout(safetyTimeout);
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
clearInterval(connectionCheckInterval);
|
||||||
|
try { popup.close(); } catch { /* COOP may block close across origins */ }
|
||||||
|
if (connected) {
|
||||||
|
checkConnection().then(() => {
|
||||||
|
cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setConnectError('Google Search Console connection was cancelled or failed.');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Listen for postMessage from callback page (primary mechanism)
|
||||||
const messageHandler = (event: MessageEvent) => {
|
const messageHandler = (event: MessageEvent) => {
|
||||||
if (messageHandled) return;
|
if (resolved) return;
|
||||||
if (!event?.data || typeof event.data !== 'object') return;
|
if (!event?.data || typeof event.data !== 'object') return;
|
||||||
const { type } = event.data as { type?: string };
|
const { type } = event.data as { type?: string };
|
||||||
|
|
||||||
if (type === 'GSC_AUTH_SUCCESS' || type === 'GSC_AUTH_ERROR') {
|
if (type === 'GSC_AUTH_SUCCESS') {
|
||||||
messageHandled = true;
|
finish(true);
|
||||||
try { popup.close(); } catch {}
|
} else if (type === 'GSC_AUTH_ERROR') {
|
||||||
window.removeEventListener('message', messageHandler);
|
finish(false);
|
||||||
|
|
||||||
if (type === 'GSC_AUTH_SUCCESS') {
|
|
||||||
checkConnection().then(() => {
|
|
||||||
cachedAnalyticsAPI.forceRefreshAnalyticsData(['gsc']).catch(console.error);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setConnectError('Google Search Console connection was cancelled or failed.');
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('message', messageHandler);
|
window.addEventListener('message', messageHandler);
|
||||||
|
|
||||||
const safetyTimeout = setTimeout(() => {
|
// 2. Poll popup.closed (works when popup stays same-origin)
|
||||||
if (!messageHandled) {
|
|
||||||
try { if (!popup.closed) popup.close(); } catch {}
|
|
||||||
window.removeEventListener('message', messageHandler);
|
|
||||||
checkConnection().then(() => resolve());
|
|
||||||
}
|
|
||||||
}, 3 * 60 * 1000);
|
|
||||||
|
|
||||||
const pollInterval = setInterval(() => {
|
const pollInterval = setInterval(() => {
|
||||||
|
if (resolved) return;
|
||||||
try {
|
try {
|
||||||
if (popup.closed) {
|
if (popup.closed) {
|
||||||
clearInterval(pollInterval);
|
// Popup closed — check if connection succeeded
|
||||||
clearTimeout(safetyTimeout);
|
checkConnection().then((connected) => {
|
||||||
window.removeEventListener('message', messageHandler);
|
if (connected) {
|
||||||
if (!messageHandled) {
|
finish(true);
|
||||||
checkConnection().then(() => resolve());
|
} else if (!resolved) {
|
||||||
}
|
// Popup closed without connecting — give a brief window for backend to finish
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
checkConnection().then((c) => finish(c));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
clearInterval(pollInterval);
|
// COOP blocks popup.closed access; rely on other mechanisms
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 500);
|
||||||
|
|
||||||
|
// 3. Poll backend connection status (works even when postMessage is blocked)
|
||||||
|
// Checks every 2s after a 1s initial delay to let the OAuth flow complete
|
||||||
|
let checkCount = 0;
|
||||||
|
const connectionCheckInterval = setInterval(() => {
|
||||||
|
if (resolved) return;
|
||||||
|
checkCount++;
|
||||||
|
if (checkCount < 2) return; // Skip first 2 checks (1s) to let OAuth start
|
||||||
|
checkConnection().then((connected) => {
|
||||||
|
if (connected) finish(true);
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
// 4. Safety timeout
|
||||||
|
const safetyTimeout = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
checkConnection().then((connected) => finish(connected));
|
||||||
|
}
|
||||||
|
}, 2 * 60 * 1000); // 2 min safety timeout (reduced from 3)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GSC OAuth error:', error);
|
console.error('GSC OAuth error:', error);
|
||||||
|
|||||||
@@ -645,11 +645,12 @@ export interface AssistiveSuggestion {
|
|||||||
export interface AssistiveSuggestionResponse {
|
export interface AssistiveSuggestionResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
suggestions: AssistiveSuggestion[];
|
suggestions: AssistiveSuggestion[];
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assistiveWritingApi = {
|
export const assistiveWritingApi = {
|
||||||
async getSuggestion(text: string): Promise<AssistiveSuggestionResponse> {
|
async getSuggestion(text: string, cursorPosition?: number): Promise<AssistiveSuggestionResponse> {
|
||||||
const { data } = await aiApiClient.post('/api/writing-assistant/suggest', { text });
|
const { data } = await aiApiClient.post('/api/writing-assistant/suggest', { text, cursor_position: cursorPosition });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Service for calling the hallucination detector API endpoints.
|
* Service for calling the hallucination detector API endpoints.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { longRunningApiClient } from '../api/client';
|
||||||
|
|
||||||
export interface SourceDocument {
|
export interface SourceDocument {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -75,7 +77,6 @@ export interface HealthCheckResponse {
|
|||||||
|
|
||||||
class HallucinationDetectorService {
|
class HallucinationDetectorService {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private authTokenGetter: (() => Promise<string | null>) | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const getApiBaseUrl = () => {
|
const getApiBaseUrl = () => {
|
||||||
@@ -88,19 +89,9 @@ class HallucinationDetectorService {
|
|||||||
this.baseUrl = getApiBaseUrl();
|
this.baseUrl = getApiBaseUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {
|
// Kept for backward compatibility — auth is now handled by longRunningApiClient interceptors
|
||||||
this.authTokenGetter = getter;
|
setAuthTokenGetter(_getter: (() => Promise<string | null>) | null) {
|
||||||
}
|
// no-op
|
||||||
|
|
||||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
if (this.authTokenGetter) {
|
|
||||||
const token = await this.authTokenGetter();
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,28 +100,17 @@ class HallucinationDetectorService {
|
|||||||
async detectHallucinations(request: HallucinationDetectionRequest): Promise<HallucinationDetectionResponse> {
|
async detectHallucinations(request: HallucinationDetectionRequest): Promise<HallucinationDetectionResponse> {
|
||||||
console.log('🔍 [HallucinationDetectorService] detectHallucinations called with request:', request);
|
console.log('🔍 [HallucinationDetectorService] detectHallucinations called with request:', request);
|
||||||
try {
|
try {
|
||||||
const url = `${this.baseUrl}/api/hallucination-detector/detect`;
|
const url = `/api/hallucination-detector/detect`;
|
||||||
console.log('🔍 [HallucinationDetectorService] Making request to:', url);
|
console.log('🔍 [HallucinationDetectorService] Making request to:', url);
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await longRunningApiClient.post(url, request);
|
||||||
method: 'POST',
|
|
||||||
headers: await this.getAuthHeaders(),
|
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔍 [HallucinationDetectorService] Response status:', response.status, 'OK:', response.ok);
|
console.log('🔍 [HallucinationDetectorService] Response status:', response.status, 'OK:', response.status === 200);
|
||||||
|
console.log('🔍 [HallucinationDetectorService] Response data:', response.data);
|
||||||
if (!response.ok) {
|
return response.data;
|
||||||
const errorText = await response.text();
|
} catch (error: any) {
|
||||||
console.error('🔍 [HallucinationDetectorService] HTTP error response:', errorText);
|
|
||||||
throw new Error(`HTTP error! status: ${response.status} - ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('🔍 [HallucinationDetectorService] Response data:', data);
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('🔍 [HallucinationDetectorService] Error detecting hallucinations:', error);
|
console.error('🔍 [HallucinationDetectorService] Error detecting hallucinations:', error);
|
||||||
|
const errorMessage = error?.response?.data?.error || error?.response?.data?.message || error?.message || 'Unknown error occurred';
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
claims: [],
|
claims: [],
|
||||||
@@ -140,7 +120,7 @@ class HallucinationDetectorService {
|
|||||||
refuted_claims: 0,
|
refuted_claims: 0,
|
||||||
insufficient_claims: 0,
|
insufficient_claims: 0,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
error: errorMessage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,26 +130,16 @@ class HallucinationDetectorService {
|
|||||||
*/
|
*/
|
||||||
async extractClaims(request: ClaimExtractionRequest): Promise<ClaimExtractionResponse> {
|
async extractClaims(request: ClaimExtractionRequest): Promise<ClaimExtractionResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/extract-claims`, {
|
const response = await longRunningApiClient.post('/api/hallucination-detector/extract-claims', request);
|
||||||
method: 'POST',
|
return response.data;
|
||||||
headers: await this.getAuthHeaders(),
|
} catch (error: any) {
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error extracting claims:', error);
|
console.error('Error extracting claims:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
claims: [],
|
claims: [],
|
||||||
total_claims: 0,
|
total_claims: 0,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
error: error?.response?.data?.error || error?.message || 'Unknown error occurred'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,19 +149,9 @@ class HallucinationDetectorService {
|
|||||||
*/
|
*/
|
||||||
async verifyClaim(request: ClaimVerificationRequest): Promise<ClaimVerificationResponse> {
|
async verifyClaim(request: ClaimVerificationRequest): Promise<ClaimVerificationResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/verify-claim`, {
|
const response = await longRunningApiClient.post('/api/hallucination-detector/verify-claim', request);
|
||||||
method: 'POST',
|
return response.data;
|
||||||
headers: await this.getAuthHeaders(),
|
} catch (error: any) {
|
||||||
body: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying claim:', error);
|
console.error('Error verifying claim:', error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -204,7 +164,7 @@ class HallucinationDetectorService {
|
|||||||
reasoning: 'Error during verification'
|
reasoning: 'Error during verification'
|
||||||
},
|
},
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
error: error?.response?.data?.error || error?.message || 'Unknown error occurred'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,15 +174,9 @@ class HallucinationDetectorService {
|
|||||||
*/
|
*/
|
||||||
async healthCheck(): Promise<HealthCheckResponse> {
|
async healthCheck(): Promise<HealthCheckResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/health`);
|
const response = await longRunningApiClient.get('/api/hallucination-detector/health');
|
||||||
|
return response.data;
|
||||||
if (!response.ok) {
|
} catch (error: any) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking health:', error);
|
console.error('Error checking health:', error);
|
||||||
return {
|
return {
|
||||||
status: 'unhealthy',
|
status: 'unhealthy',
|
||||||
@@ -239,14 +193,8 @@ class HallucinationDetectorService {
|
|||||||
*/
|
*/
|
||||||
async getDemoInfo(): Promise<any> {
|
async getDemoInfo(): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/demo`);
|
const response = await longRunningApiClient.get('/api/hallucination-detector/demo');
|
||||||
|
return response.data;
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting demo info:', error);
|
console.error('Error getting demo info:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user