On main: session-work-2026-05-22

This commit is contained in:
ajaysi
2026-05-23 13:09:41 +05:30
40 changed files with 1870 additions and 859 deletions

View File

@@ -44,7 +44,7 @@ CORE_ROUTER_REGISTRY = [
OPTIONAL_ROUTER_REGISTRY = [
{"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": "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": "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"}},

View File

@@ -9,10 +9,12 @@ from fastapi import APIRouter, HTTPException, Depends
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from loguru import logger
from datetime import datetime
from middleware.auth_middleware import get_current_user
from sqlalchemy.orm import Session
from services.database import get_db as get_db_dependency
from utils.text_asset_tracker import save_and_track_text_content
from models.content_asset_models import AssetType, AssetSource
from models.blog_models import (
BlogResearchRequest,
@@ -36,6 +38,7 @@ from models.blog_models import (
from services.blog_writer.blog_service import BlogWriterService
from services.blog_writer.seo.blog_seo_recommendation_applier import BlogSEORecommendationApplier
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 .cache_manager import cache_manager
from models.blog_models import MediumBlogGenerateRequest
@@ -1260,3 +1263,233 @@ async def save_complete_blog_asset(
except Exception as e:
logger.error(f"Failed to save complete blog asset: {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

View File

@@ -256,7 +256,8 @@ class TaskManager:
self.task_storage[task_id]["status"] = "running"
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 2040 seconds.")
await self.update_progress(task_id, "📦 Packaging your outline sections and research data...")
# Basic guard: respect global target words
total_target = int(request.globalTargetWords or 1000)
@@ -281,16 +282,22 @@ class TaskManager:
# Check if result came from cache
cache_hit = getattr(result, 'cache_hit', False)
if cache_hit:
await self.update_progress(task_id, "⚡ Found cached content - loading instantly!")
await self.update_progress(task_id, "⚡ Found existing content in cache — no need to regenerate!")
else:
await self.update_progress(task_id, "🤖 Generated fresh content with AI...")
await self.update_progress(task_id, "✨ Post-processing and assembling sections...")
await self.update_progress(task_id, "🧠 AI is writing each section with research-backed insights and natural flow...")
await self.update_progress(task_id, "✨ Polishing content — improving structure, readability, and transitions...")
# Mark completed
self.task_storage[task_id]["status"] = "completed"
self.task_storage[task_id]["result"] = result.dict()
await self.update_progress(task_id, f"✅ Generated {len(result.sections)} sections successfully.")
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
# to ensure we have proper database session and user context

View File

@@ -71,7 +71,7 @@ async def detect_hallucinations(request: HallucinationDetectionRequest, current_
text=source.get('text', ''),
published_date=source.get('publishedDate'),
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
]
@@ -83,7 +83,7 @@ async def detect_hallucinations(request: HallucinationDetectionRequest, current_
text=source.get('text', ''),
published_date=source.get('publishedDate'),
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
]
@@ -214,7 +214,7 @@ async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str
text=source.get('text', ''),
published_date=source.get('publishedDate'),
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
]
@@ -226,7 +226,7 @@ async def verify_claim(request: ClaimVerificationRequest, current_user: Dict[str
text=source.get('text', ''),
published_date=source.get('publishedDate'),
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
]

View File

@@ -12,6 +12,7 @@ router = APIRouter(prefix="/api/writing-assistant", tags=["writing-assistant"])
class SuggestRequest(BaseModel):
text: str
cursor_position: int | None = None
class SourceModel(BaseModel):
@@ -32,6 +33,7 @@ class SuggestionModel(BaseModel):
class SuggestResponse(BaseModel):
success: bool
suggestions: List[SuggestionModel]
message: str = ""
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:
try:
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(
success=True,
success=len(suggestions) > 0,
suggestions=[
SuggestionModel(
text=s.text,

View File

@@ -679,9 +679,6 @@ if _is_full_mode():
if 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"] = {
"mounted": True,
"reason": "Full mode",
@@ -692,6 +689,10 @@ else:
"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)
if _is_feature_enabled("podcast") and "all" not in get_enabled_features():
from api.podcast.router import router as podcast_router

View File

@@ -76,12 +76,22 @@ async def handle_gsc_callback(
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:
logger.info("GSC OAuth callback handled successfully")
# Create GSC insights task immediately after successful connection
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
# 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
if user_id:
db = SessionLocal()
try:
# Create insights task without site_url to avoid API calls
# The executor will fetch it when the task runs (weekly)
task_result = create_platform_insights_task(
user_id=user_id,
platform='gsc',
site_url=None, # Will be fetched by executor when task runs
db=db
)
if task_result.get('success'):
logger.info(f"Created GSC insights task for user {user_id}")
else:
logger.warning(f"Failed to create GSC insights task: {task_result.get('error')}")
finally:
db.close()
db = get_session_for_user(user_id)
if db:
try:
task_result = create_platform_insights_task(
user_id=user_id,
platform='gsc',
site_url=None,
db=db
)
if task_result.get('success'):
logger.info(f"Created GSC insights task for user {user_id}")
else:
logger.warning(f"Failed to create GSC insights task: {task_result.get('error')}")
finally:
db.close()
else:
logger.warning(f"Could not create DB session for user {user_id}")
else:
logger.warning(f"Could not extract user_id from state: {state}")
except Exception as e:
@@ -125,7 +136,10 @@ async def handle_gsc_callback(
</body>
</html>
"""
return HTMLResponse(content=html)
return HTMLResponse(
content=html,
headers={"Cross-Origin-Opener-Policy": "unsafe-none"},
)
else:
logger.error("Failed to handle GSC OAuth callback")
html = """
@@ -140,7 +154,11 @@ async def handle_gsc_callback(
</body>
</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:
logger.error(f"Error handling GSC OAuth callback: {e}")
@@ -157,7 +175,11 @@ async def handle_gsc_callback(
</body>
</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")
async def get_gsc_sites(user: dict = Depends(get_current_user)):

View File

@@ -122,9 +122,6 @@ class MediumBlogGenerator:
payload = {
"title": req.title,
"globalTargetWords": req.globalTargetWords or 1000,
"persona": req.persona.dict() if req.persona else None,
"tone": req.tone,
"audience": req.audience,
"sections": [section_block(s) for s in req.sections],
}
@@ -136,7 +133,6 @@ class MediumBlogGenerator:
- Industry: {req.persona.industry or 'General'}
- Tone: {req.persona.tone or 'Professional'}
- 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.
Use industry-specific terminology and examples where appropriate.
@@ -154,40 +150,19 @@ class MediumBlogGenerator:
"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 = (
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"
"For each section, write engaging content that:\n"
"- Follows the key points provided\n"
"- Uses the suggested keywords naturally\n"
"- Meets the target word count\n"
"- Maintains professional tone\n"
"- References the provided sources when relevant\n"
"- Breaks content into clear paragraphs (2-4 sentences each)\n"
"- Uses double line breaks (\\n\\n) between paragraphs for proper formatting\n"
"- Uses double line breaks (\\n\\n) between paragraphs\n"
"- Starts with an engaging opening paragraph\n"
"- Ends with a strong concluding paragraph\n"
f"{persona_instructions}\n"
"IMPORTANT: Format the 'content' field with proper paragraph breaks using \\n\\n between paragraphs.\n\n"
"Return a JSON object with 'title' and 'sections' array. Each section should have 'id', 'heading', 'content', and 'wordCount'.\n\n"
f"Sections to write:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
"- Ends with a strong concluding paragraph\n\n"
"Return a JSON object with 'title' and 'sections' array. Each section must have 'id', 'heading', 'content', 'wordCount', and 'sources'.\n\n"
f"Sections:\n{json.dumps(payload, ensure_ascii=False, indent=2)}"
)
try:
@@ -195,7 +170,9 @@ class MediumBlogGenerator:
prompt=prompt,
json_struct=schema,
system_prompt=system,
user_id=user_id
user_id=user_id,
max_tokens=None,
temperature=0.3,
)
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit) to preserve error details

View File

@@ -322,7 +322,7 @@ class ExaResearchProvider(BaseProvider):
'text': getattr(result, 'text', ''),
'publishedDate': getattr(result, 'publishedDate', ''),
'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

View File

@@ -31,6 +31,7 @@ from models.product_marketing_models import Campaign, CampaignProposal, Campaign
from models.product_asset_models import ProductAsset, ProductStyleTemplate, EcommerceExport
# Podcast Maker models use SubscriptionBase, but import to ensure models are registered
from models.podcast_models import PodcastProject
# Research models use SubscriptionBase
from models.research_models import ResearchProject
# Video Studio models

View File

@@ -2,8 +2,9 @@
GSC Brainstorm Service for ALwrity.
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)
with LLM-powered strategic recommendations tailored to the user's topic intent.
Combines rule-based heuristics with LLM-powered strategic recommendations tailored to
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
@@ -21,9 +22,10 @@ class GSCBrainstormService:
Flow:
1. Fetch real GSC search analytics (query + page data, 30 days)
2. Apply rule-based filters (Content Optimization, Content Enhancement, Keyword Gap)
3. Generate LLM-powered strategic recommendations contextualised to the user's keywords
4. Return structured results
2. Compute derived metrics (CTR benchmarks, estimated traffic uplift, content formats)
3. Apply rule-based filters (Quick Wins, Optimization, Enhancement, Rising Stars, Page Issues)
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):
@@ -39,18 +41,8 @@ class GSCBrainstormService:
keywords: str,
site_url: Optional[str] = None,
) -> 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
# 1. Resolve site_url
if not site_url:
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.",
"content_opportunities": [],
"keyword_gaps": [],
"quick_wins": [],
"page_opportunities": [],
"ai_recommendations": {},
"summary": {},
}
@@ -80,6 +74,8 @@ class GSCBrainstormService:
"error": analytics.get("error", "Failed to fetch GSC data"),
"content_opportunities": [],
"keyword_gaps": [],
"quick_wins": [],
"page_opportunities": [],
"ai_recommendations": {},
"summary": {},
}
@@ -93,9 +89,11 @@ class GSCBrainstormService:
if not keywords_data:
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": [],
"keyword_gaps": [],
"quick_wins": [],
"page_opportunities": [],
"ai_recommendations": {},
"summary": {
"site_url": site_url,
@@ -107,18 +105,23 @@ class GSCBrainstormService:
# 4. Rule-based analysis
content_opportunities = self._identify_content_opportunities(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
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(
keywords_data, pages_data, summary, keywords
keywords_data, pages_data, summary, keywords,
content_opportunities, quick_wins, keyword_gaps,
)
return {
"content_opportunities": content_opportunities,
"keyword_gaps": keyword_gaps,
"quick_wins": quick_wins,
"page_opportunities": page_opportunities,
"ai_recommendations": ai_recommendations,
"summary": summary,
}
@@ -168,39 +171,53 @@ class GSCBrainstormService:
opportunities: List[Dict[str, Any]] = []
# 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:
if kw["impressions"] > 500 and kw["ctr"] < 3:
estimated_gain = int(kw["impressions"] * 0.05) - kw["clicks"]
opportunities.append({
"type": "Content Optimization",
"keyword": kw["keyword"],
"opportunity": (
f"Optimize existing content for '{kw['keyword']}' "
f"to improve CTR from {kw['ctr']:.1f}% "
f"(position {kw['position']:.1f})"
f"Your site appears for '{kw['keyword']}' ({kw['impressions']:,} times/month) "
f"but only {kw['ctr']:.1f}% click. Improving your title and meta description "
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_ctr": kw["ctr"],
"impressions": kw["impressions"],
"clicks": kw["clicks"],
"estimated_traffic_gain": max(estimated_gain, 5),
"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
# 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:
if 10 < kw["position"] <= 20 and kw["impressions"] > 100:
estimated_gain = int(kw["impressions"] * 0.08)
opportunities.append({
"type": "Content Enhancement",
"keyword": kw["keyword"],
"opportunity": (
f"Enhance content for '{kw['keyword']}' to move from "
f"position {kw['position']:.1f} to the first page"
f"'{kw['keyword']}' ranks #{kw['position']:.0f} (page 2). "
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_ctr": kw["ctr"],
"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)
return opportunities[:10]
@@ -212,15 +229,111 @@ class GSCBrainstormService:
for kw in keywords_data:
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({
"keyword": kw["keyword"],
"position": kw["position"],
"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)
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
# ------------------------------------------------------------------ #
@@ -248,6 +361,16 @@ class GSCBrainstormService:
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]
# 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 {
"site_url": site_url,
"date_range": {"start": start_date, "end": end_date},
@@ -256,6 +379,8 @@ class GSCBrainstormService:
"total_clicks": total_clicks,
"avg_ctr": avg_ctr,
"avg_position": avg_position,
"ctr_vs_benchmark": ctr_vs_benchmark,
"health_score": health_score,
"keyword_distribution": {
"positions_1_3": pos_1_3,
"positions_4_10": pos_4_10,
@@ -263,11 +388,22 @@ class GSCBrainstormService:
"positions_21_plus": pos_21_plus,
},
"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
],
"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
],
}
@@ -282,60 +418,110 @@ class GSCBrainstormService:
pages_data: List[Dict],
summary: Dict,
user_keywords: str,
content_opportunities: List[Dict],
quick_wins: List[Dict],
keyword_gaps: List[Dict],
) -> Dict[str, Any]:
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", {})
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:
- Total Keywords Tracked: {summary.get('total_keywords_analyzed', 0)}
prompt = f"""You are an expert SEO content strategist analyzing real Google Search Console data for a blog writer.
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 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}
- SEO Health Score: {summary.get('health_score', 0)}/100
TOP PERFORMING KEYWORDS:
{top_kw}
TOP KEYWORDS BY IMPRESSIONS:
{top_kw_str}
KEYWORD POSITION DISTRIBUTION:
- Positions 1-3: {dist.get('positions_1_3', 0)}
- Positions 4-10: {dist.get('positions_4_10', 0)}
- Positions 11-20: {dist.get('positions_11_20', 0)}
- Positions 21+: {dist.get('positions_21_plus', 0)}
- Position 1-3 (top results): {dist.get('positions_1_3', 0)} keywords
- Position 4-10 (page 1): {dist.get('positions_4_10', 0)} keywords
- Position 11-20 (page 2): {dist.get('positions_11_20', 0)} keywords
- 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):
- Specific blog post titles the user should write
- Each tied to a keyword opportunity from the data
- 3-5 suggestions
For each suggestion include:
1. A specific, compelling blog post TITLE (not vague topic)
2. The keyword it targets and why (based on the data above)
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):
- 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:
Return your response in this EXACT JSON format (no markdown, no code fences):
{{
"immediate_opportunities": ["topic 1", "topic 2", "topic 3"],
"content_strategy": ["strategy 1", "strategy 2", "strategy 3"],
"long_term_strategy": ["vision 1", "vision 2", "vision 3"]
}}"""
"immediate_opportunities": [
{{
"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 = (
"You are an enterprise SEO content strategist. Provide specific, data-driven "
"blog topic suggestions that will improve the user's search performance. "
"Always respond with valid JSON matching the requested format."
"You are an expert SEO content strategist. You analyze Google Search Console data "
"and provide specific, actionable blog post recommendations that will drive real traffic. "
"You always respond with valid JSON matching the requested format. "
"Every recommendation must be backed by the data provided."
)
result = llm_text_gen(
@@ -350,27 +536,58 @@ Return your response in this exact JSON format:
if 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:
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(raw: str) -> Optional[Dict[str, List[str]]]:
def _parse_ai_response(self, raw: str) -> Optional[Dict[str, Any]]:
try:
json_start = raw.find("{")
json_end = raw.rfind("}") + 1
# Strip markdown code fences if present
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:
return None
chunk = raw[json_start:json_end]
chunk = cleaned[json_start:json_end]
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 {
"immediate_opportunities": parsed.get("immediate_opportunities", [])[:5],
"content_strategy": parsed.get("content_strategy", [])[:5],
"long_term_strategy": parsed.get("long_term_strategy", [])[:5],
"immediate_opportunities": normalize_section(parsed.get("immediate_opportunities", []))[:5],
"content_strategy": normalize_section(parsed.get("content_strategy", []))[:5],
"long_term_strategy": normalize_section(parsed.get("long_term_strategy", []))[:5],
}
except (json.JSONDecodeError, ValueError) as 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
def _fallback_ai_recommendations(
keywords_data: List[Dict],
content_opportunities: List[Dict],
quick_wins: List[Dict],
) -> Dict[str, Any]:
top_kw = keywords_data[:3] if keywords_data else []
immediate = []
for kw in top_kw:
immediate.append(
f"Write a comprehensive guide on '{kw['keyword']}' "
f"(currently at position {kw['position']:.1f} with "
f"{kw['impressions']} impressions)"
)
# Build from quick wins first (highest ROI)
for qw in quick_wins[:2]:
immediate.append({
"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 {
"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": [
"Develop topic clusters around your top-performing keywords",
"Create comparison and vs-style content for competitive terms",
"Build FAQ sections targeting question-based queries",
{"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"},
{"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"},
{"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": [
"Build domain authority through pillar content",
"Expand into adjacent topic areas",
"Develop thought leadership content series",
{"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"},
{"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"},
{"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"},
],
}

View File

@@ -250,10 +250,10 @@ class GSCService:
flow = Flow.from_client_config(
self.client_config,
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)
state = f"{user_id}:{random_state}"
@@ -300,7 +300,7 @@ class GSCService:
logger.error(f"User database not found for user {user_id}")
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:
cursor = conn.cursor()
cursor.execute('SELECT user_id FROM gsc_oauth_states WHERE state = ?', (state,))
@@ -309,10 +309,6 @@ class GSCService:
if not result:
logger.error(f"Invalid or expired GSC OAuth state for user {user_id}")
return False
# Clean up state
cursor.execute('DELETE FROM gsc_oauth_states WHERE state = ?', (state,))
conn.commit()
# Exchange code for credentials
if not self.client_config:
@@ -322,12 +318,22 @@ class GSCService:
flow = Flow.from_client_config(
self.client_config,
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)
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
return self.save_user_credentials(user_id, credentials)

View File

@@ -343,18 +343,28 @@ class HallucinationDetector:
logger.error(f"Error in batch evidence search: {str(e)}")
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]:
"""Assess multiple claims against sources in one LLM call."""
try:
claims_to_assess = claims[:3]
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)
])
claims_text = "\n".join([
f"Claim {i+1}: {claim}"
f"Claim {i}: {claim}"
for i, claim in enumerate(claims_to_assess)
])
@@ -367,12 +377,14 @@ class HallucinationDetector:
' "claim_index": 0,\n'
' "assessment": "supported" or "refuted" or "insufficient_information",\n'
' "confidence": number between 0.0 and 1.0,\n'
' "supporting_sources": [array of source indices that support the claim],\n'
' "refuting_sources": [array of source indices that refute 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 0-based source indices, e.g. [1] for Source [1]],\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 every 'supported' or 'refuted' claim you MUST include the relevant source indices.\n\n"
f"Claims to verify:\n{claims_text}\n\n"
f"Sources:\n{combined_sources}\n\n"
"Return only the JSON object:"
@@ -407,6 +419,15 @@ class HallucinationDetector:
if isinstance(idx, int) and 0 <= idx < len(sources):
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(
text=claim,
confidence=float(assessment.get('confidence', 0.5)),
@@ -464,7 +485,7 @@ class HallucinationDetector:
"""Assess whether sources support or refute the claim using LLM."""
try:
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)
])
@@ -474,10 +495,12 @@ class HallucinationDetector:
"{\n"
' "assessment": "supported" or "refuted" or "insufficient_information",\n'
' "confidence": number between 0.0 and 1.0,\n'
' "supporting_sources": [array of source indices that support the claim],\n'
' "refuting_sources": [array of source indices that refute 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 0-based source indices, e.g. [1] for Source [1]],\n'
' "reasoning": "brief explanation of your assessment"\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"Sources:\n{combined_sources}\n\n"
"Return only the JSON object:"
@@ -508,6 +531,15 @@ class HallucinationDetector:
if isinstance(idx, int) and 0 <= idx < len(sources):
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
valid_assessments = ['supported', 'refuted', 'insufficient_information']
if result['assessment'] not in valid_assessments:

View File

@@ -46,6 +46,7 @@ def llm_text_gen(
preferred_provider: Optional[str] = None,
flow_type: Optional[str] = None,
max_tokens: Optional[int] = None,
temperature: Optional[float] = None,
) -> str:
"""
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_provider (str, optional): Preferred provider (google, huggingface).
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:
str: Generated text based on the prompt.
@@ -75,9 +78,8 @@ def llm_text_gen(
# Set default values for LLM parameters
gpt_provider = "google" # Default to Google Gemini
model = "gemini-2.0-flash-001"
temperature = 0.7
if max_tokens is None:
max_tokens = 4000
if temperature is None:
temperature = 0.7
top_p = 0.9
n = 1
fp = 16

View File

@@ -1,6 +1,7 @@
import os
import re
import asyncio
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from dataclasses import dataclass
from loguru import logger
import random
@@ -17,42 +18,33 @@ class WritingSuggestion:
class WritingAssistantService:
"""
Minimal writing assistant that combines Exa search with Gemini continuation.
- Exa provides relevant sources with content snippets
- Gemini generates a short, cited continuation based on current text and sources
Writing assistant that combines Exa search with LLM continuation.
- Searches relevant sources using the content near the cursor position
- Generates a short continuation grounded in sources
- Confidence derived from source availability and quality
"""
def __init__(self) -> None:
# COST CONTROL: Daily usage limits
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
def _get_cached_suggestion(self, text: str) -> WritingSuggestion | None:
"""No cached suggestions - always use real API calls for authentic results."""
return None
def _check_daily_limit(self) -> bool:
"""Check if we're within daily API usage limits."""
import datetime
today = datetime.date.today()
# Reset counter if it's a new day
if self.last_reset_date != today:
self.daily_api_calls = 0
self.last_reset_date = today
# Check if we've exceeded the limit
if self.daily_api_calls >= self.daily_limit:
return False
# Increment counter for this API call
self.daily_api_calls += 1
logger.info(f"Writing assistant API call #{self.daily_api_calls}/{self.daily_limit} today")
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:
return []
@@ -67,26 +59,41 @@ class WritingAssistantService:
if len(text.strip()) < 50:
return []
# 1) Find relevant sources via Exa
sources = await self._search_sources(text, user_id=user_id)
# Use text before cursor for context (where the user is actively writing)
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
suggestion_text, confidence = await self._generate_continuation(text, sources, user_id=user_id)
# 1) Find relevant sources via Exa (non-fatal)
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:
return []
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]]:
"""Search for relevant sources using ExaResearchProvider with subscription checks."""
async def _search_sources(self, context_text: str, user_id: str = None) -> List[Dict[str, Any]]:
"""Search Exa using the last sentence before cursor for a focused query."""
try:
from services.blog_writer.research.exa_provider import ExaResearchProvider
exa_query = (
(text[-1000:] if len(text) > 1000 else text)
+ "\n\nIf you found the above interesting, here's another useful resource to read:"
)
# Extract the last sentence from context to use as a focused search query
sentences = re.split(r'(?<=[.!?])\s+', context_text.strip())
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()
sources = await provider.simple_search(
@@ -95,7 +102,6 @@ class WritingAssistantService:
user_id=user_id,
)
# Normalize keys to match expected format
normalized = []
for s in sources:
normalized.append({
@@ -104,7 +110,7 @@ class WritingAssistantService:
"text": s.get("text", ""),
"author": s.get("author", ""),
"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:
@@ -151,8 +157,21 @@ class WritingAssistantService:
suggestion = (str(ai_resp or "")).strip()
if not 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:
logger.error(f"WritingAssistant _generate_continuation error: {e}")
raise

View File

@@ -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.');
}
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;
}
// In development, use localhost by default
const envUrl = process.env.REACT_APP_API_URL;
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';
// Development fallback when no env var is configured
return 'http://localhost:8000';
};
// Create a shared axios instance for all API calls

View File

@@ -6,20 +6,56 @@ export interface ContentOpportunity {
opportunity: string;
potential_impact: 'High' | 'Medium';
current_position: number;
current_ctr: number;
impressions: number;
clicks: number;
estimated_traffic_gain: number;
priority: 'High' | 'Medium';
suggested_format: string;
}
export interface KeywordGap {
keyword: string;
position: 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 {
immediate_opportunities: string[];
content_strategy: string[];
long_term_strategy: string[];
immediate_opportunities: AIRecommendation[];
content_strategy: AIRecommendation[];
long_term_strategy: AIRecommendation[];
}
export interface BrainstormSummary {
@@ -30,20 +66,24 @@ export interface BrainstormSummary {
total_clicks: number;
avg_ctr: number;
avg_position: number;
ctr_vs_benchmark: number;
health_score: number;
keyword_distribution: {
positions_1_3: number;
positions_4_10: number;
positions_11_20: number;
positions_21_plus: number;
};
top_keywords: Array<{ keyword: string; impressions: number; position: number }>;
top_pages: Array<{ page: string; clicks: number; impressions: number }>;
top_keywords: Array<{ keyword: string; impressions: number; clicks: number; position: number; ctr: number }>;
top_pages: Array<{ page: string; clicks: number; impressions: number; ctr: number }>;
}
export interface BrainstormResult {
error?: string;
content_opportunities: ContentOpportunity[];
keyword_gaps: KeywordGap[];
quick_wins: QuickWin[];
page_opportunities: PageOpportunity[];
ai_recommendations: AIRecommendations | Record<string, never>;
summary: BrainstormSummary | Record<string, never>;
}

View File

@@ -1,5 +1,5 @@
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 DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
@@ -9,6 +9,7 @@ import Button from '@mui/material/Button';
import { debug } from '../../utils/debug';
import WriterCopilotSidebar from './BlogWriterUtils/WriterCopilotSidebar';
import { blogWriterApi } from '../../services/blogWriterApi';
import { researchCache } from '../../services/researchCache';
import { useClaimFixer } from '../../hooks/useClaimFixer';
import { useMarkdownProcessor } from '../../hooks/useMarkdownProcessor';
import { useBlogWriterState } from '../../hooks/useBlogWriterState';
@@ -34,6 +35,7 @@ import { useModalVisibility } from './BlogWriterUtils/useModalVisibility';
import { useBlogWriterRefs } from './BlogWriterUtils/useBlogWriterRefs';
import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSection';
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
import { useBlogAsset } from '../../hooks/useBlogAsset';
const BlogWriter: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
@@ -205,6 +207,8 @@ const BlogWriter: React.FC = () => {
// Store navigateToPhase in a ref for use in polling callbacks
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
const handleSectionsUpdate = useCallback((newSections: Record<string, string>) => {
@@ -221,6 +225,83 @@ const BlogWriter: React.FC = () => {
}
}, [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
const {
researchPolling,
@@ -231,7 +312,7 @@ const BlogWriter: React.FC = () => {
outlinePollingState,
mediumPollingState,
} = useBlogWriterPolling({
onResearchComplete: handleResearchComplete,
onResearchComplete: wrappedHandleResearchComplete,
onOutlineComplete: handleOutlineComplete,
onOutlineError: handleOutlineError,
onSectionsUpdate: handleSectionsUpdate,
@@ -239,6 +320,10 @@ const BlogWriter: React.FC = () => {
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
setContentConfirmed(true);
},
onContentError: () => {
debug.log('[BlogWriter] Content generation failed - reverting outline confirmation');
setOutlineConfirmed(false);
},
navigateToPhase: (phase) => {
debug.log('[BlogWriter] Navigating to phase after content generation', { phase });
// Use ref to access navigateToPhase (defined later in component)
@@ -248,6 +333,7 @@ const BlogWriter: React.FC = () => {
}, 0);
}
},
skipContentAutoConfirmRef,
});
// Modal visibility management - extracted to useModalVisibility
@@ -304,11 +390,13 @@ const BlogWriter: React.FC = () => {
const handlePhaseClick = useCallback((phaseId: string) => {
navigateToPhase(phaseId);
// When clicking Research phase, ensure we navigate to research phase (this will trigger research form to show)
if (phaseId === 'research' && !research) {
debug.log('[BlogWriter] Research phase clicked - navigating to research phase to show form');
// navigateToPhase already called above, which will set currentPhase to 'research'
// BlogWriterLandingSection will detect currentPhase === 'research' and show ManualResearchForm
if (phaseId === 'research') {
if (!currentPhase) {
setResearch(null);
debug.log('[BlogWriter] Research phase clicked from landing - cleared research to show form');
} else {
debug.log('[BlogWriter] Research phase clicked - showing existing research data');
}
}
if (phaseId === 'seo') {
if (seoAnalysis) {
@@ -318,7 +406,7 @@ const BlogWriter: React.FC = () => {
runSEOAnalysisDirect();
}
}
}, [navigateToPhase, seoAnalysis, research, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
}, [navigateToPhase, currentPhase, seoAnalysis, research, setResearch, runSEOAnalysisDirect, setIsSEOAnalysisModalOpen]);
const handleNewBlog = useCallback(() => {
setResearch(null);
@@ -339,12 +427,16 @@ const BlogWriter: React.FC = () => {
localStorage.removeItem('blogwriter_user_selected_phase');
localStorage.removeItem('blog_content_confirmed');
localStorage.removeItem('blog_seo_recommendations_applied');
localStorage.removeItem('blog_last_asset_id');
} catch {
// ignore localStorage errors
}
researchCache.clearCache();
resetAsset();
setSearchParams({}, { replace: true });
}, [setResearch, setOutline, setSections, setSeoAnalysis, setSeoMetadata,
setContentConfirmed, setOutlineConfirmed, setSelectedTitle, setTitleOptions,
setCurrentPhase]);
setCurrentPhase, resetAsset, setSearchParams]);
// Handle ?new=true query param from "New Blog" button in Asset Library
React.useEffect(() => {
@@ -354,12 +446,12 @@ const BlogWriter: React.FC = () => {
}
}, [searchParams, handleNewBlog, setSearchParams]);
const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false);
const handleMyBlogs = useCallback(() => {
navigate('/asset-library?source_module=blog_writer&asset_type=text');
}, [navigate]);
const [newBlogDialogOpen, setNewBlogDialogOpen] = useState(false);
const hasExistingWork = !!(research || outline.length > 0 || Object.keys(sections).length > 0);
const confirmNewBlog = useCallback(() => {
@@ -401,6 +493,7 @@ const BlogWriter: React.FC = () => {
selectedTitle,
contentConfirmed,
sections,
seoAnalysis,
navigateToPhase,
handleOutlineConfirmed,
setIsMediumGenerationStarting,
@@ -411,7 +504,8 @@ const BlogWriter: React.FC = () => {
setIsSEOAnalysisModalOpen,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onResearchComplete: handleResearchComplete,
skipContentAutoConfirmRef,
onResearchComplete: wrappedHandleResearchComplete,
onOutlineComplete: handleCachedOutlineComplete,
onContentComplete: handleCachedContentComplete,
});
@@ -433,7 +527,7 @@ const BlogWriter: React.FC = () => {
isSEOAnalysisModalOpen,
lastSEOModalOpenRef,
runSEOAnalysisDirect,
confirmBlogContent,
confirmBlogContent: wrappedConfirmBlogContent,
sections,
research,
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
@@ -461,11 +555,11 @@ const BlogWriter: React.FC = () => {
outlineConfirmed={outlineConfirmed}
sections={sections}
selectedTitle={selectedTitle}
onResearchComplete={handleResearchComplete}
onResearchComplete={wrappedHandleResearchComplete}
onOutlineCreated={setOutline}
onOutlineUpdated={setOutline}
onTitleOptionsSet={setTitleOptions}
onOutlineConfirmed={handleOutlineConfirmed}
onOutlineConfirmed={wrappedHandleOutlineConfirmed}
onOutlineRefined={(feedback?: string) => handleOutlineRefined(feedback || '')}
onMediumGenerationStarted={handleMediumGenerationStarted}
onMediumGenerationTriggered={handleMediumGenerationTriggered}
@@ -516,10 +610,21 @@ const BlogWriter: React.FC = () => {
buildUpdatedMarkdownForClaim={buildUpdatedMarkdownForClaim}
applyClaimFix={applyClaimFix}
/>
<Publisher
<Publisher
buildFullMarkdown={buildFullMarkdown}
convertMarkdownToHTML={convertMarkdownToHTML}
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 */}
@@ -540,7 +645,7 @@ const BlogWriter: React.FC = () => {
hasResearch={!!research}
hasOutline={outline.length > 0}
outlineConfirmed={outlineConfirmed}
hasContent={Object.keys(sections).length > 0}
hasContent={hasContent}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={!!seoAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
@@ -557,7 +662,8 @@ const BlogWriter: React.FC = () => {
copilotKitAvailable={copilotKitAvailable}
currentPhase={currentPhase}
navigateToPhase={navigateToPhase}
onResearchComplete={handleResearchComplete}
onResearchComplete={wrappedHandleResearchComplete}
onBeforeResearchSubmit={handleBeforeResearchSubmit}
restoreAttempted={restoreAttempted}
/>
@@ -592,7 +698,7 @@ const BlogWriter: React.FC = () => {
onTitleSelect={handleTitleSelect}
onCustomTitle={handleCustomTitle}
copilotKitAvailable={copilotKitAvailable}
onResearchComplete={handleResearchComplete}
onResearchComplete={wrappedHandleResearchComplete}
onOutlineGenerationStart={(taskId) => {
setOutlineTaskId(taskId);
outlinePolling.startPolling(taskId);
@@ -628,7 +734,7 @@ const BlogWriter: React.FC = () => {
blogTitle={selectedTitle}
researchData={research}
onApplyRecommendations={handleApplySeoRecommendations}
onAnalysisComplete={handleSEOAnalysisComplete}
onAnalysisComplete={wrappedHandleSEOAnalysisComplete}
/>
{/* SEO Metadata Modal */}

View File

@@ -9,6 +9,7 @@ interface BlogWriterLandingSectionProps {
currentPhase: string;
navigateToPhase: (phase: string) => void;
onResearchComplete: (research: any) => void;
onBeforeResearchSubmit?: (keywords: string, blogLength: string) => Promise<void>;
restoreAttempted?: boolean;
}
@@ -20,11 +21,12 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
currentPhase,
navigateToPhase,
onResearchComplete,
onBeforeResearchSubmit,
restoreAttempted = false,
}) => {
if (!research) {
if (currentPhase === 'research') {
return <ManualResearchForm onResearchComplete={onResearchComplete} />;
return <ManualResearchForm onResearchComplete={onResearchComplete} onBeforeResearchSubmit={onBeforeResearchSubmit} />;
}
if (currentPhase === '' || !VALID_PHASES.includes(currentPhase)) {

View File

@@ -6,6 +6,7 @@ import {
useRewritePolling,
} from '../../../hooks/usePolling';
import { blogWriterCache } from '../../../services/blogWriterCache';
import { debug } from '../../../utils/debug';
interface UseBlogWriterPollingProps {
onResearchComplete: (research: any) => void;
@@ -13,7 +14,9 @@ interface UseBlogWriterPollingProps {
onOutlineError: (error: any) => void;
onSectionsUpdate: (sections: Record<string, string>) => void;
onContentConfirmed?: () => void; // Callback when content generation completes
onContentError?: () => void; // Callback when content generation fails
navigateToPhase?: (phase: string) => void; // Phase navigation function
skipContentAutoConfirmRef?: React.MutableRefObject<boolean>; // When true, skip auto-confirm & navigation after content generation
}
export const useBlogWriterPolling = ({
@@ -22,7 +25,9 @@ export const useBlogWriterPolling = ({
onOutlineError,
onSectionsUpdate,
onContentConfirmed,
onContentError,
navigateToPhase,
skipContentAutoConfirmRef,
}: UseBlogWriterPollingProps) => {
// Research polling hook (for context awareness) - uses blog writer endpoint
const researchPolling = useBlogWriterResearchPolling({
@@ -47,36 +52,22 @@ export const useBlogWriterPolling = ({
});
onSectionsUpdate(newSections);
// Auto-confirm content and navigate to SEO phase when content generation completes
// This happens when user clicks "Next:Confirm and generate content"
if (onContentConfirmed) {
onContentConfirmed();
}
if (navigateToPhase) {
navigateToPhase('seo');
}
// Save to asset library (dedup by title is handled inside saveBlogToAssetLibrary)
// Backend also saves via save_and_track_text_content; this is a safety net / metadata update
(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);
// Skip auto-confirm and navigation when Re-Content was used
// (user already had content and chose to regenerate — stay on content phase to review)
const skipAutoConfirm = skipContentAutoConfirmRef?.current === true;
if (skipContentAutoConfirmRef) skipContentAutoConfirmRef.current = false; // reset flag
if (skipAutoConfirm) {
debug.log('[BlogWriter] Re-Content: skipping auto-confirm and navigation (user stays on content phase)');
} else {
// Auto-confirm content and navigate to SEO phase when content generation completes
// This happens for initial content generation (first time)
if (onContentConfirmed) {
onContentConfirmed();
}
})();
if (navigateToPhase) {
navigateToPhase('seo');
}
}
}
} catch (e) {
console.error('Failed to apply medium generation result:', e);
@@ -84,11 +75,12 @@ export const useBlogWriterPolling = ({
},
onError: (err: any) => {
console.error('Medium generation failed:', err);
onContentError?.();
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'))) {
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')) {
setTimeout(() => alert('Content generation failed due to a provider error. This might be a temporary issue — please try again or switch providers.'), 100);
} else if (errMsg.includes('no valid structured response') || errMsg.includes('parse') || errMsg.includes('json')) {
setTimeout(() => alert('Content generation failed because the AI provider returned an unparseable response. This is usually a temporary issue — please try again.'), 100);
}
}
});

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
interface UseModalVisibilityProps {
mediumPolling: { isPolling: boolean };
@@ -37,16 +37,24 @@ export const useModalVisibility = ({
}
}, [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(() => {
if (outlinePolling.isPolling && !showOutlineModal) {
setShowOutlineModal(true);
} else if (!outlinePolling.isPolling && showOutlineModal) {
// Add a small delay to ensure user sees completion message
setTimeout(() => {
outlineHideRef.current = setTimeout(() => {
setShowOutlineModal(false);
outlineHideRef.current = null;
}, 1000);
}
return () => {
if (outlineHideRef.current) {
clearTimeout(outlineHideRef.current);
outlineHideRef.current = null;
}
};
}, [outlinePolling.isPolling, showOutlineModal]);
return {

View File

@@ -10,6 +10,7 @@ interface UsePhaseActionHandlersProps {
selectedTitle: string | null;
contentConfirmed: boolean;
sections: Record<string, string>;
seoAnalysis: any;
navigateToPhase: (phase: string) => void;
handleOutlineConfirmed: () => void;
setIsMediumGenerationStarting: (starting: boolean) => void;
@@ -20,6 +21,7 @@ interface UsePhaseActionHandlersProps {
setIsSEOAnalysisModalOpen: (open: boolean) => void;
setIsSEOMetadataModalOpen: (open: boolean) => void;
runSEOAnalysisDirect: () => string;
skipContentAutoConfirmRef?: React.MutableRefObject<boolean>;
onResearchComplete?: (research: any) => void;
onOutlineComplete?: (outline: any) => void;
onContentComplete?: (sections: Record<string, string>) => void;
@@ -31,6 +33,7 @@ export const usePhaseActionHandlers = ({
selectedTitle,
contentConfirmed,
sections,
seoAnalysis,
navigateToPhase,
handleOutlineConfirmed,
setIsMediumGenerationStarting,
@@ -41,32 +44,14 @@ export const usePhaseActionHandlers = ({
setIsSEOAnalysisModalOpen,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
skipContentAutoConfirmRef,
onResearchComplete,
onOutlineComplete,
onContentComplete,
}: UsePhaseActionHandlersProps) => {
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, onResearchComplete, research]);
}, [navigateToPhase]);
const handleOutlineAction = useCallback(async () => {
if (!research) {
@@ -105,7 +90,7 @@ export const usePhaseActionHandlers = ({
const handleContentAction = useCallback(async () => {
if (!outline || outline.length === 0) {
alert('Please generate and confirm an outline first.');
alert('Please generate an outline first.');
return;
}
if (!research) {
@@ -117,22 +102,33 @@ export const usePhaseActionHandlers = ({
// Confirm outline first
handleOutlineConfirmed();
// Check cache first (shared utility)
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) {
debug.log('[BlogWriter] Using cached content from localStorage', { sections: Object.keys(cachedContent).length });
if (onContentComplete) {
onContentComplete(cachedContent);
}
return;
// Signal to polling callback: if content was already confirmed (Re-Content),
// skip auto-confirm and SEO navigation so user stays on content phase to review
if (skipContentAutoConfirmRef && hasExistingContent) {
skipContentAutoConfirmRef.current = true;
debug.log('[BlogWriter] Re-Content: setting skipAutoConfirm flag');
}
// Also check if sections already exist in current state (shared utility)
if (blogWriterCache.contentExistsInState(sections || {}, outlineIds)) {
debug.log('[BlogWriter] Content already exists in state, skipping generation', { sections: Object.keys(sections || {}).length });
return;
// Only use cache for initial generation (when no content exists yet).
// "Re-Content" label means user explicitly wants to regenerate, so skip cache.
if (!hasExistingContent) {
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
@@ -183,13 +179,17 @@ export const usePhaseActionHandlers = ({
const handleSEOAction = useCallback(() => {
if (!contentConfirmed) {
// Mark content as confirmed when SEO action is clicked
setContentConfirmed(true);
}
navigateToPhase('seo');
runSEOAnalysisDirect();
debug.log('[BlogWriter] SEO action triggered - running SEO analysis');
}, [contentConfirmed, setContentConfirmed, navigateToPhase, runSEOAnalysisDirect]);
if (seoAnalysis) {
setIsSEOAnalysisModalOpen(true);
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(() => {
navigateToPhase('seo');

View File

@@ -26,8 +26,11 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
brainstormError,
contentOpportunities,
keywordGaps,
quickWins,
pageOpportunities,
aiRecommendations,
summary,
progressMessage,
connectGSC,
brainstorm,
reset,
@@ -36,7 +39,6 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
const wordCount = keywords.trim().split(/\s+/).filter(Boolean).length;
const isVisible = wordCount >= 3;
// Auto-trigger brainstorm after GSC connection succeeds
useEffect(() => {
if (gscConnected && pendingBrainstormRef.current && !isConnecting) {
pendingBrainstormRef.current = false;
@@ -100,7 +102,7 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
}
style={{
padding: '12px 20px',
backgroundColor: disabled || isBrainstorming ? '#ccc' : '#4caf50',
backgroundColor: disabled || isBrainstorming ? '#999' : '#4caf50',
color: 'white',
border: 'none',
borderRadius: '6px',
@@ -144,10 +146,13 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
}}
contentOpportunities={contentOpportunities}
keywordGaps={keywordGaps}
quickWins={quickWins}
pageOpportunities={pageOpportunities}
aiRecommendations={aiRecommendations}
summary={summary}
error={brainstormError}
isBrainstorming={isBrainstorming}
progressMessage={progressMessage}
onSelectSuggestion={handleSelectSuggestion}
/>
@@ -165,10 +170,6 @@ export const BrainstormButton: React.FC<BrainstormButtonProps> = ({
);
};
/* ------------------------------------------------------------------ */
/* GSC Connection Overlay */
/* ------------------------------------------------------------------ */
const GSConnectOverlay: React.FC<{
isConnecting: boolean;
connectError: string | null;
@@ -177,7 +178,6 @@ const GSConnectOverlay: React.FC<{
onSuccess: () => void;
onCancel: () => void;
}> = ({ isConnecting, connectError, gscConnected, onConnect, onSuccess, onCancel }) => {
// If connection just succeeded, auto-proceed
if (gscConnected && !isConnecting) {
onSuccess();
return null;

View File

@@ -2,7 +2,10 @@ import React from 'react';
import {
ContentOpportunity,
KeywordGap,
QuickWin,
PageOpportunity,
AIRecommendations,
AIRecommendation,
BrainstormSummary,
} from '../../api/gscBrainstorm';
@@ -11,14 +14,23 @@ interface GSCBrainstormModalProps {
onClose: () => void;
contentOpportunities: ContentOpportunity[];
keywordGaps: KeywordGap[];
quickWins: QuickWin[];
pageOpportunities: PageOpportunity[];
aiRecommendations: AIRecommendations | null;
summary: BrainstormSummary | null;
error: string | null;
isBrainstorming: boolean;
progressMessage?: string;
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];
export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
@@ -26,225 +38,223 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
onClose,
contentOpportunities,
keywordGaps,
quickWins,
pageOpportunities,
aiRecommendations,
summary,
error,
isBrainstorming,
progressMessage,
onSelectSuggestion,
}) => {
const [activeTab, setActiveTab] = React.useState<TabKey>('Opportunities');
const [activeTab, setActiveTab] = React.useState<TabKey>('Quick Wins');
if (!open) return null;
const hasNoData =
!isBrainstorming &&
!error &&
contentOpportunities.length === 0 &&
keywordGaps.length === 0 &&
!aiRecommendations;
const hasData =
contentOpportunities.length > 0 ||
keywordGaps.length > 0 ||
quickWins.length > 0 ||
pageOpportunities.length > 0 ||
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 (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.55)', display: 'flex',
alignItems: 'center', justifyContent: 'center', zIndex: 9999,
backdropFilter: 'blur(2px)',
}}
onClick={onClose}
>
<div
style={{
backgroundColor: '#fff',
borderRadius: '12px',
width: '90%',
maxWidth: '720px',
maxHeight: '85vh',
borderRadius: '16px',
width: '85vw',
height: '85vh',
maxWidth: '1200px',
maxHeight: '900px',
display: 'flex',
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()}
>
{/* Header */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 24px',
borderBottom: '1px solid #e0e0e0',
}}
>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '20px 28px', borderBottom: '1px solid #e8e8e8', flexShrink: 0,
}}>
<div>
<h3 style={{ margin: 0, fontSize: '18px', color: '#333' }}>
<h3 style={{ margin: 0, fontSize: '20px', fontWeight: 600, color: '#1a1a1a' }}>
Brainstorm Topics with GSC Data
</h3>
{summary && (
<p style={{ margin: '4px 0 0', fontSize: '12px', color: '#888' }}>
{summary?.site_url && (
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#888' }}>
{summary.site_url} &middot; {summary.date_range?.start} to {summary.date_range?.end} &middot;{' '}
{summary.total_keywords_analyzed} keywords analyzed
{summary.total_keywords_analyzed} keywords
</p>
)}
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '20px',
cursor: 'pointer',
color: '#888',
padding: '4px 8px',
background: 'none', border: 'none', fontSize: '22px', cursor: 'pointer',
color: '#999', padding: '4px 10px', borderRadius: '6px',
transition: 'background-color 0.15s', lineHeight: 1,
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f0f0'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
aria-label="Close"
>
x
</button>
></button>
</div>
{/* Summary metrics bar */}
{/* Summary dashboard */}
{summary && summary.total_keywords_analyzed > 0 && (
<div
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>
<SummaryDashboard summary={summary} />
)}
{/* Loading */}
{/* Loading with educational progress */}
{isBrainstorming && (
<div
style={{
padding: '48px 24px',
textAlign: 'center',
}}
>
<div
style={{
width: '40px',
height: '40px',
border: '3px solid #e0e0e0',
borderTopColor: '#1976d2',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 16px',
}}
/>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
<p style={{ color: '#666', margin: 0 }}>
Analyzing your GSC data and generating topic suggestions...
</p>
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '48px', gap: '24px',
}}>
<div style={{ position: 'relative', width: '72px', height: '72px' }}>
<div style={{
position: 'absolute', inset: 0,
borderRadius: '50%', border: '4px solid #e8e8e8',
}} />
<div style={{
position: 'absolute', inset: 0,
borderRadius: '50%', border: '4px solid transparent',
borderTopColor: '#1976d2', borderRightColor: '#4caf50',
animation: 'progressSpin 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite',
}} />
<style>{`@keyframes progressSpin { to { transform: rotate(360deg); } }`}</style>
</div>
<div style={{ textAlign: 'center', maxWidth: '520px' }}>
{progressMessage ? (
<>
<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>
)}
{/* Error */}
{error && !isBrainstorming && (
<div
style={{
padding: '24px',
textAlign: 'center',
}}
>
<p style={{ color: '#d32f2f', margin: '0 0 8px', fontWeight: 500 }}>
{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 style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: '48px',
}}>
<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: '#888', margin: 0, fontSize: '13px' }}>Make sure your Google Search Console is connected and has data for the last 30 days.</p>
</div>
)}
{/* No data */}
{hasNoData && (
<div
style={{
padding: '48px 24px',
textAlign: 'center',
}}
>
<p style={{ color: '#888', margin: 0 }}>
No brainstorming data available. Try different keywords or check your GSC connection.
</p>
{!isBrainstorming && !error && !hasData && (
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: '48px',
}}>
<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>
</div>
)}
{/* Results */}
{!isBrainstorming && !error && !hasNoData && (
{!isBrainstorming && !error && hasData && (
<>
{/* Tabs */}
<div
style={{
display: 'flex',
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#fafafa',
}}
>
<div style={{
display: 'flex', borderBottom: '1px solid #e8e8e8',
backgroundColor: '#fafafa', padding: '0 4px', flexShrink: 0,
}}>
{tabLabels.map((tab) => {
const count =
tab === 'Opportunities'
? 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;
const count = getTabCount(tab);
const isActive = activeTab === tab;
return (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '10px 20px',
border: 'none',
borderBottom: activeTab === tab ? '2px solid #1976d2' : '2px solid transparent',
background: activeTab === tab ? '#fff' : 'transparent',
color: activeTab === tab ? '#1976d2' : '#666',
fontWeight: activeTab === tab ? 600 : 400,
cursor: 'pointer',
fontSize: '13px',
padding: '12px 20px', border: 'none',
borderBottom: isActive ? '2px solid #1976d2' : '2px solid transparent',
background: isActive ? '#fff' : 'transparent',
color: isActive ? '#1976d2' : '#666',
fontWeight: isActive ? 600 : 400,
cursor: 'pointer', fontSize: '14px', whiteSpace: 'nowrap',
transition: 'color 0.15s, background-color 0.15s',
display: 'flex', alignItems: 'center', gap: '6px',
}}
>
{tab}
{count > 0 && (
<span
style={{
marginLeft: '6px',
backgroundColor: activeTab === tab ? '#1976d2' : '#ccc',
color: '#fff',
borderRadius: '10px',
padding: '1px 7px',
fontSize: '11px',
}}
>
{count}
</span>
<span style={{
backgroundColor: isActive ? '#1976d2' : '#bbb',
color: '#fff', borderRadius: '10px', padding: '1px 8px',
fontSize: '11px', fontWeight: 600, lineHeight: '18px',
}}>{count}</span>
)}
</button>
);
@@ -252,48 +262,34 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
</div>
{/* Tab content */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
{activeTab === 'Opportunities' && (
<OpportunitiesTab
opportunities={contentOpportunities}
onSelect={onSelectSuggestion}
/>
)}
{activeTab === 'Keyword Gaps' && (
<GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />
)}
{activeTab === 'AI Recommendations' && (
<AIRecommendationsTab
recommendations={aiRecommendations}
onSelect={onSelectSuggestion}
/>
)}
<div style={{ flex: 1, overflow: 'auto', padding: '20px 28px' }}>
{activeTab === 'Quick Wins' && <QuickWinsTab wins={quickWins} onSelect={onSelectSuggestion} />}
{activeTab === 'Opportunities' && <OpportunitiesTab opportunities={contentOpportunities} onSelect={onSelectSuggestion} />}
{activeTab === 'Keyword Gaps' && <GapsTab gaps={keywordGaps} onSelect={onSelectSuggestion} />}
{activeTab === 'Pages' && <PagesTab pages={pageOpportunities} />}
{activeTab === 'AI Recommendations' && <AIRecommendationsTab recommendations={aiRecommendations} onSelect={onSelectSuggestion} />}
</div>
</>
)}
{/* Footer */}
<div
style={{
padding: '12px 24px',
borderTop: '1px solid #e0e0e0',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<div style={{
padding: '14px 28px', borderTop: '1px solid #e8e8e8',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
backgroundColor: '#fafafa', flexShrink: 0,
}}>
<span style={{ fontSize: '12px', color: '#999' }}>Click any keyword or title to use it as your research topic</span>
<button
onClick={onClose}
style={{
padding: '8px 20px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
padding: '10px 24px', backgroundColor: '#fff',
border: '1px solid #ddd', borderRadius: '8px',
cursor: 'pointer', fontSize: '14px', color: '#555',
transition: 'background-color 0.15s',
}}
>
Close
</button>
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f5f5f5'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
>Close</button>
</div>
</div>
</div>
@@ -301,196 +297,326 @@ export const GSCBrainstormModal: React.FC<GSCBrainstormModalProps> = ({
};
/* ------------------------------------------------------------------ */
/* Sub-components */
/* Summary Dashboard */
/* ------------------------------------------------------------------ */
const OpportunitiesTab: React.FC<{
opportunities: ContentOpportunity[];
onSelect: (keyword: string) => void;
}> = ({ opportunities, onSelect }) => {
const SummaryDashboard: React.FC<{ summary: BrainstormSummary }> = ({ summary }) => {
const dist = summary.keyword_distribution || {};
const total = dist.positions_1_3 + dist.positions_4_10 + dist.positions_11_20 + dist.positions_21_plus || 1;
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 &middot; {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) {
return <EmptyMessage message="No content opportunities found for this period." />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{opportunities.map((opp, i) => (
<div
key={i}
style={{
padding: '12px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
cursor: 'pointer',
transition: 'background-color 0.15s',
padding: '16px 18px', border: '1px solid #e0e0e0', borderRadius: '10px',
cursor: 'pointer', transition: 'all 0.15s',
borderLeft: `4px solid ${opp.priority === 'High' ? '#d32f2f' : '#f57c00'}`,
}}
onClick={() => onSelect(opp.keyword)}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f7ff'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '4px',
}}
>
<span style={{ fontWeight: 600, fontSize: '14px', color: '#333' }}>
{opp.keyword}
</span>
<div style={{ display: 'flex', gap: '6px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px' }}>
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{opp.keyword}</span>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Badge
label={opp.type === 'Content Optimization' ? 'Optimize' : 'Enhance'}
color={opp.type === 'Content Optimization' ? '#1565c0' : '#f57c00'}
/>
<Badge
label={opp.priority}
color={opp.priority === 'High' ? '#d32f2f' : '#666'}
/>
<Badge label={opp.priority} color={opp.priority === 'High' ? '#d32f2f' : '#666'} />
{opp.suggested_format && <Badge label={opp.suggested_format} color="#6a1b9a" />}
</div>
</div>
<p style={{ margin: '0 0 4px', fontSize: '13px', color: '#555' }}>
{opp.opportunity}
</p>
<div style={{ fontSize: '12px', color: '#999' }}>
{opp.impressions.toLocaleString()} impressions &middot; Position {opp.current_position}
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#444', lineHeight: 1.5 }}>{opp.opportunity}</p>
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#888', flexWrap: 'wrap' }}>
<span>{opp.impressions.toLocaleString()} impressions</span>
<span>Position {opp.current_position}</span>
<span>{opp.current_ctr}% CTR</span>
<span style={{ color: '#2e7d32', fontWeight: 600 }}>+{opp.estimated_traffic_gain} clicks/mo potential</span>
</div>
</div>
))}
<p style={{ fontSize: '12px', color: '#aaa', margin: '8px 0 0' }}>
Click any keyword to use it as your research topic.
</p>
</div>
);
};
const GapsTab: React.FC<{
gaps: KeywordGap[];
onSelect: (keyword: string) => void;
}> = ({ gaps, onSelect }) => {
/* ------------------------------------------------------------------ */
/* Keyword Gaps Tab */
/* ------------------------------------------------------------------ */
const GapsTab: React.FC<{ gaps: KeywordGap[]; onSelect: (kw: string) => void }> = ({ gaps, onSelect }) => {
if (gaps.length === 0) {
return (
<EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />
);
return <EmptyMessage message="No keyword gaps identified. Your rankings look solid for this period." />;
}
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) => (
<div
key={i}
style={{
padding: '12px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
transition: 'background-color 0.15s',
padding: '14px 16px', border: '1px solid #e0e0e0', borderRadius: '10px',
cursor: 'pointer', display: 'flex', justifyContent: 'space-between',
alignItems: 'center', transition: 'background-color 0.15s',
}}
onClick={() => onSelect(gap.keyword)}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#f0f7ff')}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = '#fff')}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#f0f7ff'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#fff'}
>
<span style={{ fontWeight: 500, fontSize: '14px' }}>{gap.keyword}</span>
<div style={{ fontSize: '12px', color: '#999' }}>
Position {gap.position} &middot; {gap.impressions.toLocaleString()} impressions
<div>
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{gap.keyword}</span>
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
{gap.current_ctr}% CTR &middot; {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>
))}
<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>
);
};
const AIRecommendationsTab: React.FC<{
recommendations: AIRecommendations | null;
onSelect: (keyword: string) => void;
}> = ({ recommendations, onSelect }) => {
if (!recommendations) {
return <EmptyMessage message="AI recommendations are not available right now." />;
/* ------------------------------------------------------------------ */
/* Pages Tab */
/* ------------------------------------------------------------------ */
const PagesTab: React.FC<{ pages: PageOpportunity[] }> = ({ pages }) => {
if (pages.length === 0) {
return <EmptyMessage message="No page-level issues found. Your pages are performing well." />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<RecommendationSection
title="Immediate Opportunities (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 style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<p style={{ margin: '0 0 4px', fontSize: '14px', color: '#555' }}>
These pages get significant impressions but low click-through rates. Improving their titles and meta descriptions can boost clicks.
</p>
{pages.map((pg, i) => (
<div key={i} style={{
padding: '16px 18px', border: '1px solid #e0e0e0', borderRadius: '10px',
borderLeft: '4px solid #d32f2f',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '15px', color: '#1a1a1a' }}>{pg.page_title}</span>
<Badge label={`${pg.current_ctr}% CTR`} color={pg.current_ctr < 1 ? '#d32f2f' : '#f57c00'} />
</div>
<p style={{ margin: '0 0 8px', fontSize: '13px', color: '#555', lineHeight: 1.5 }}>{pg.reason}</p>
<div style={{ fontSize: '12px', color: '#888' }}>
{pg.impressions.toLocaleString()} impressions &middot; {pg.clicks} clicks &middot; Position {pg.current_position}
</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '6px', wordBreak: 'break-all' }}>{pg.page}</div>
</div>
))}
</div>
);
};
const RecommendationSection: React.FC<{
title: string;
items: string[];
onSelect: (keyword: string) => void;
color: string;
}> = ({ title, items, onSelect, color }) => {
/* ------------------------------------------------------------------ */
/* AI Recommendations Tab */
/* ------------------------------------------------------------------ */
const AIRecommendationsTab: React.FC<{ recommendations: AIRecommendations | null; onSelect: (kw: string) => void }> = ({ recommendations, onSelect }) => {
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;
return (
<div>
<h4 style={{ margin: '0 0 8px', fontSize: '14px', color }}>{title}</h4>
<ul style={{ margin: 0, paddingLeft: '20px', listStyle: 'disc' }}>
<h4 style={{
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) => (
<li
<div
key={i}
style={{
fontSize: '13px',
color: '#444',
marginBottom: '4px',
cursor: 'pointer',
padding: '14px 16px', border: '1px solid #e8e8e8', borderRadius: '10px',
cursor: 'pointer', transition: 'all 0.15s',
}}
onClick={() => {
const short = item.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
if (short) onSelect(short);
const kw = item.keyword || item.title.split(/[:(]/)[0].replace(/^[-\s]+/, '').trim();
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}
</li>
<div style={{ fontWeight: 600, fontSize: '14px', color: '#1a1a1a', marginBottom: '4px' }}>{item.title}</div>
{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>
);
};
/* ------------------------------------------------------------------ */
/* Shared */
/* ------------------------------------------------------------------ */
const Badge: React.FC<{ label: string; color: string }> = ({ label, color }) => (
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '4px',
color: '#fff',
backgroundColor: color,
}}
>
{label}
</span>
<span style={{
fontSize: '11px', fontWeight: 600, padding: '3px 10px',
borderRadius: '5px', color: '#fff', backgroundColor: color,
whiteSpace: 'nowrap',
}}>{label}</span>
);
const EmptyMessage: React.FC<{ message: string }> = ({ message }) => (
<div style={{ padding: '32px 0', textAlign: 'center' }}>
<p style={{ color: '#888', margin: 0 }}>{message}</p>
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<p style={{ color: '#888', margin: 0, fontSize: '14px' }}>{message}</p>
</div>
);

View File

@@ -6,9 +6,10 @@ import { BrainstormButton } from './BrainstormButton';
interface ManualResearchFormProps {
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 [blogLength, setBlogLength] = useState('1000');
@@ -30,6 +31,7 @@ export const ManualResearchForm: React.FC<ManualResearchFormProps> = ({ onResear
return;
}
try {
await onBeforeResearchSubmit?.(trimmed, blogLength);
await startResearch(trimmed, blogLength);
} catch (err) {
alert(`Research failed: ${err instanceof Error ? err.message : 'Unknown error'}`);

View File

@@ -1,4 +1,4 @@
import React, { forwardRef, useImperativeHandle } from 'react';
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import { useCopilotAction } from '@copilotkit/react-core';
import { blogWriterApi, BlogResearchResponse } from '../../services/blogWriterApi';
import { blogWriterCache } from '../../services/blogWriterCache';
@@ -22,6 +22,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
navigateToPhase,
onOutlineCreated
}, 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)
useImperativeHandle(ref, () => ({
generateNow: async () => {
@@ -29,13 +32,16 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
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)
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
if (cachedOutline) {
console.log('[OutlineGenerator] Using cached outline', { sections: cachedOutline.outline.length });
// Return cached result - caller should handle setting outline state
return {
success: true,
cached: true,
@@ -44,6 +50,7 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
};
}
outlineGenInProgressRef.current = true;
try {
onModalShow?.();
const { task_id } = await blogWriterApi.startOutlineGeneration({ research });
@@ -53,6 +60,8 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
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.' };
}
if (outlineGenInProgressRef.current) {
return { success: false, message: 'Outline generation is already in progress. Please wait...' };
}
// Check cache first (shared utility)
const researchKeywords = research.original_keywords || research.keyword_analysis?.primary || [];
const cachedOutline = blogWriterCache.getCachedOutline(researchKeywords);
@@ -89,6 +102,7 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
};
}
outlineGenInProgressRef.current = true;
try {
// Navigate to outline phase when outline generation starts
navigateToPhase?.('outline');
@@ -129,6 +143,8 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
success: false,
message: userMessage
};
} finally {
outlineGenInProgressRef.current = false;
}
},
render: ({ status }: any) => {

View File

@@ -27,6 +27,8 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
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.';
}
// Outline phase messages
if (message.includes('Starting outline generation')) {
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!';
}
// 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 2040 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 message;
};
@@ -137,7 +161,9 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
fontWeight: '700',
textShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)'
}}>
{titleOverride || (status === 'complete' ? '🎉 Outline Ready!' : status === 'error' ? '❌ Generation Failed' : '🧩 Creating Your Blog Outline')}
{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>
{/* Progress Bar */}
@@ -165,15 +191,15 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
}}>
{titleOverride
? (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'
? 'Something went wrong during generation'
: 'AI is generating your blog content...')
? 'Content generation encountered an issue. You can retry from the content phase.'
: 'Alwrity is writing your blog content using AI...')
: (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'
? 'Something went wrong during outline generation'
: 'AI is analyzing your research and creating the perfect blog structure...')}
? 'Outline generation encountered an issue. Please try again.'
: 'Alwrity is analyzing your research and building your blog structure...')}
</p>
</div>
</div>
@@ -188,14 +214,21 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
padding: '16px',
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>
) : (
<>
{/* Current Status */}
<div style={{
backgroundColor: '#f0f9ff',
border: '1px solid #bae6fd',
backgroundColor: status === 'complete' ? '#f0fdf4' : '#f0f9ff',
border: `1px solid ${status === 'complete' ? '#bbf7d0' : '#bae6fd'}`,
borderRadius: '8px',
padding: '16px',
marginBottom: '20px'
@@ -203,7 +236,7 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
<div style={{
fontSize: '14px',
fontWeight: '600',
color: '#0369a1',
color: status === 'complete' ? '#15803d' : '#0369a1',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
@@ -215,16 +248,17 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
height: '8px',
borderRadius: '50%',
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 style={{
fontSize: '15px',
color: '#1e40af',
lineHeight: '1.5'
color: status === 'complete' ? '#166534' : '#1e40af',
lineHeight: '1.5',
whiteSpace: 'pre-wrap'
}}>
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing to generate your outline...'}
{latestMessage ? getUserFriendlyMessage(latestMessage) : 'Preparing...'}
</div>
</div>
@@ -235,7 +269,7 @@ export const OutlineProgressModal: React.FC<OutlineProgressModalProps> = ({
margin: '0 0 12px 0',
fontSize: '14px',
fontWeight: '600',
color: '#374151'
color: '#374151'
}}>
Progress Timeline
</h4>

View File

@@ -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.',
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.',
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.',
};
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> = {
research: 'Enter keywords to research your topic',
outline: 'Create your blog outline to structure your content',
@@ -91,19 +101,13 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
}
break;
case 'content':
if (hasOutline && !outlineConfirmed) {
return { label: 'Confirm & Generate Content', handler: actionHandlers.onContentAction || null };
if (hasOutline) {
return { label: hasContent ? 'Re-Content' : 'Generate Content', handler: actionHandlers.onContentAction || null };
}
break;
case 'seo':
if (hasContent && !hasSEOAnalysis) {
return { label: '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 };
if (hasContent) {
return { label: hasSEOAnalysis ? 'Re-Analyze SEO' : 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
}
break;
case 'publish':
@@ -202,8 +206,9 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
/* Show action button only when phase is NOT completed.
Research action: only on landing page (not current), to invite start.
Other phase actions: show when current, pending, or next-actionable. */
const showAction = action.handler && !isDone && (
Other phase actions: show when current, pending, or next-actionable.
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') ||
(!isCurrent && !isDisabled && phase.id !== 'research') ||
@@ -328,11 +333,17 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
<Tooltip
title={
<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 }}>
{isDisabled
? `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>
}
@@ -347,7 +358,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
sx={chipSx}
>
<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 && (
<Box component="span" sx={{ fontSize: '12px', flexShrink: 0, ml: 0.25 }}></Box>
)}

View File

@@ -10,6 +10,7 @@ interface PublisherProps {
buildFullMarkdown: () => string;
convertMarkdownToHTML: (md: string) => string;
seoMetadata: BlogSEOMetadataResponse | null;
onPublishComplete?: () => void;
}
const saveCompleteBlogAsset = async (
@@ -37,7 +38,8 @@ const useCopilotActionTyped = useCopilotAction as any;
export const Publisher: React.FC<PublisherProps> = ({
buildFullMarkdown,
convertMarkdownToHTML,
seoMetadata
seoMetadata,
onPublishComplete,
}) => {
const {
publishToWix,
@@ -87,6 +89,7 @@ export const Publisher: React.FC<PublisherProps> = ({
md,
seoMetadata
);
onPublishComplete?.();
}
return wixResult;
} else if (platform === 'wordpress') {
@@ -137,6 +140,7 @@ export const Publisher: React.FC<PublisherProps> = ({
if (result.success) {
saveCompleteBlogAsset(title, md, seoMetadata);
onPublishComplete?.();
return {
success: true,
url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`,

View File

@@ -241,29 +241,41 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
console.log('🔄 Force refresh requested, skipping cache check');
}
setProgressMessage('Starting SEO analysis...');
// Simulated progress
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', {
// Backend call — run concurrently with progress simulation
// Use longer timeout (120s) since SEO analysis can take 40-60s
const responsePromise = apiClient.post('/api/blog-writer/seo/analyze', {
blog_content: blogContent,
blog_title: blogTitle,
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;
console.log('🔍 Backend SEO Analysis Response:', result);

View File

@@ -107,8 +107,9 @@ const BlogSection: React.FC<BlogSectionProps> = ({
const handleContentChange = (e: any) => {
const newContent = e.target.value;
const cursorPos = e.target.selectionStart;
setContent(newContent);
assistiveWriting.handleTypingChange(newContent);
assistiveWriting.handleTypingChange(newContent, cursorPos);
};
const handleFocus = () => setIsFocused(true);

View File

@@ -61,7 +61,7 @@ const useBlogTextSelectionHandler = (
}
}, 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(() => {
console.log('🔍 [BlogTextSelectionHandler] Fact check timeout reached');
clearInterval(progressInterval);
@@ -76,9 +76,9 @@ const useBlogTextSelectionHandler = (
refuted_claims: 0,
insufficient_claims: 0,
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 {
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
useEffect(() => {
return () => {

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef, useEffect } from 'react';
import { debug } from '../../../utils/debug';
import { assistiveWritingApi } from '../../../services/blogWriterApi';
interface SmartTypingAssistProps {
contentRef: React.RefObject<HTMLDivElement | HTMLTextAreaElement>;
@@ -47,6 +48,8 @@ const useSmartTypingAssist = (
const hasShownFirstRef = useRef(false);
const isGeneratingRef = useRef(false);
const smartSuggestionRef = useRef<typeof smartSuggestion>(null);
const initialContentLengthRef = useRef<number | null>(null);
const mountedRef = useRef(true);
// Quality improvement tracking
const [suggestionStats, setSuggestionStats] = useState({
@@ -57,8 +60,8 @@ const useSmartTypingAssist = (
});
// Smart typing assist functionality
const generateSmartSuggestion = async (currentText: string) => {
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length });
const generateSmartSuggestion = async (currentText: string, cursorPosition?: number) => {
debug.log('[SmartTypingAssist] generateSmartSuggestion called', { textLength: currentText.length, cursorPosition });
if (currentText.length < 20) {
debug.log('[SmartTypingAssist] Text too short for suggestion');
@@ -70,57 +73,61 @@ const useSmartTypingAssist = (
isGeneratingRef.current = true;
try {
// Import the assistive writing API
const { assistiveWritingApi } = await import('../../../services/blogWriterApi');
if (!mountedRef.current) return;
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) {
debug.log('[SmartTypingAssist] Received suggestions from API', { count: response.suggestions.length });
if (!mountedRef.current) return;
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
setAllSuggestions(response.suggestions);
setSuggestionIndex(0);
let x = Math.max(20, Math.min(rect.left + 20, window.innerWidth - (maxWidth + 20)));
let y = rect.bottom + 10;
// 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;
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);
}
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) {
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) {
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(() => {
const cooldownMs = 15000;
const now = Date.now();
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');
generateSmartSuggestion(newText);
generateSmartSuggestion(newText, cursorPos);
setHasShownFirstSuggestion(true);
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');
setShowContinueWritingPrompt(true);
}
@@ -241,11 +258,14 @@ const useSmartTypingAssist = (
const element = contentRef.current as HTMLTextAreaElement;
const currentContent = element.value || '';
const cursorPos = element.selectionStart;
setShowContinueWritingPrompt(false);
if (currentContent.length > 20) {
await generateSmartSuggestion(currentContent);
const baseline = initialContentLengthRef.current ?? 0;
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(() => { smartSuggestionRef.current = smartSuggestion; }, [smartSuggestion]);
// Cleanup timeouts on unmount
// Mount guard and cleanup
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}

View File

@@ -73,6 +73,7 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
{/* Text Selection Menu */}
{selectionMenu && (
<div
data-selection-menu="true"
onClick={(e) => {
console.log('🔍 [TextSelectionMenu] Selection menu clicked!', e.target);
e.stopPropagation();
@@ -497,6 +498,27 @@ const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({
}}>
"{smartSuggestion.text}"
</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={{
display: 'flex',

View File

@@ -280,6 +280,10 @@ export const AssetLibrary: React.FC = () => {
setAnchorEl({ ...anchorEl, [assetId]: null });
};
const handleOpenBlogAsset = async (asset: ContentAsset) => {
navigate('/blog-writer', { state: { restoreBlogAssetId: asset.id } });
};
const handleRestoreResearchProject = async (asset: ContentAsset) => {
try {
const projectId = asset.asset_metadata?.project_id;
@@ -685,6 +689,7 @@ export const AssetLibrary: React.FC = () => {
onShare={handleShare}
onDelete={handleDelete}
onRestore={handleRestoreResearchProject}
onOpenBlogAsset={handleOpenBlogAsset}
/>
</Grid>
))}

View File

@@ -33,6 +33,7 @@ interface AssetCardProps {
onShare: (asset: ContentAsset) => void;
onDelete: (id: number) => void;
onRestore: (asset: ContentAsset) => void;
onOpenBlogAsset?: (asset: ContentAsset) => void;
}
export const AssetCard: React.FC<AssetCardProps> = ({
@@ -44,6 +45,7 @@ export const AssetCard: React.FC<AssetCardProps> = ({
onShare,
onDelete,
onRestore,
onOpenBlogAsset,
}) => {
return (
<Card
@@ -232,6 +234,18 @@ export const AssetCard: React.FC<AssetCardProps> = ({
</IconButton>
</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
size="small"
onClick={() => onDownload(asset)}

View File

@@ -1,11 +1,14 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useAuth } from '@clerk/clerk-react';
import {
gscBrainstormAPI,
BrainstormResult,
ContentOpportunity,
KeywordGap,
QuickWin,
PageOpportunity,
AIRecommendations,
AIRecommendation,
BrainstormSummary,
} from '../api/gscBrainstorm';
import { useGSCBrainstormConnection } from './useGSCBrainstormConnection';
@@ -20,13 +23,27 @@ interface UseGSCBrainstormReturn {
brainstormResult: BrainstormResult | null;
contentOpportunities: ContentOpportunity[];
keywordGaps: KeywordGap[];
quickWins: QuickWin[];
pageOpportunities: PageOpportunity[];
aiRecommendations: AIRecommendations | null;
summary: BrainstormSummary | null;
connectGSC: () => Promise<void>;
brainstorm: (keywords: string, siteUrl?: string) => Promise<BrainstormResult | null>;
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 => {
const { getToken } = useAuth();
const {
@@ -41,11 +58,45 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
const [isBrainstorming, setIsBrainstorming] = useState(false);
const [brainstormError, setBrainstormError] = useState<string | 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(
async (keywords: string, siteUrl?: string): Promise<BrainstormResult | null> => {
setIsBrainstorming(true);
setBrainstormError(null);
startProgressMessages();
try {
gscBrainstormAPI.setAuthTokenGetter(async () => {
@@ -66,6 +117,7 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
return null;
} finally {
setIsBrainstorming(false);
stopProgressMessages();
}
},
[getToken],
@@ -75,6 +127,7 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
setBrainstormResult(null);
setBrainstormError(null);
setIsBrainstorming(false);
stopProgressMessages();
}, []);
return {
@@ -87,16 +140,19 @@ export const useGSCBrainstorm = (): UseGSCBrainstormReturn => {
brainstormResult,
contentOpportunities: brainstormResult?.content_opportunities ?? [],
keywordGaps: brainstormResult?.keyword_gaps ?? [],
quickWins: brainstormResult?.quick_wins ?? [],
pageOpportunities: brainstormResult?.page_opportunities ?? [],
aiRecommendations: brainstormResult?.ai_recommendations
&& Object.keys(brainstormResult.ai_recommendations).length > 0
&& Array.isArray(brainstormResult.ai_recommendations?.immediate_opportunities)
? (brainstormResult.ai_recommendations as AIRecommendations)
: null,
summary: brainstormResult?.summary
&& Object.keys(brainstormResult.summary).length > 0
&& brainstormResult.summary.site_url
? (brainstormResult.summary as BrainstormSummary)
: null,
connectGSC,
brainstorm,
reset,
progressMessage,
};
};
};

View File

@@ -92,54 +92,84 @@ export const useGSCBrainstormConnection = (): UseGSCBrainstormConnectionReturn =
}
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) => {
if (messageHandled) return;
if (resolved) return;
if (!event?.data || typeof event.data !== 'object') return;
const { type } = event.data as { type?: string };
if (type === 'GSC_AUTH_SUCCESS' || type === 'GSC_AUTH_ERROR') {
messageHandled = true;
try { popup.close(); } catch {}
window.removeEventListener('message', messageHandler);
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();
}
if (type === 'GSC_AUTH_SUCCESS') {
finish(true);
} else if (type === 'GSC_AUTH_ERROR') {
finish(false);
}
};
window.addEventListener('message', messageHandler);
const safetyTimeout = setTimeout(() => {
if (!messageHandled) {
try { if (!popup.closed) popup.close(); } catch {}
window.removeEventListener('message', messageHandler);
checkConnection().then(() => resolve());
}
}, 3 * 60 * 1000);
// 2. Poll popup.closed (works when popup stays same-origin)
const pollInterval = setInterval(() => {
if (resolved) return;
try {
if (popup.closed) {
clearInterval(pollInterval);
clearTimeout(safetyTimeout);
window.removeEventListener('message', messageHandler);
if (!messageHandled) {
checkConnection().then(() => resolve());
}
// Popup closed — check if connection succeeded
checkConnection().then((connected) => {
if (connected) {
finish(true);
} 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 {
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) {
console.error('GSC OAuth error:', error);

View File

@@ -645,11 +645,12 @@ export interface AssistiveSuggestion {
export interface AssistiveSuggestionResponse {
success: boolean;
suggestions: AssistiveSuggestion[];
message?: string;
}
export const assistiveWritingApi = {
async getSuggestion(text: string): Promise<AssistiveSuggestionResponse> {
const { data } = await aiApiClient.post('/api/writing-assistant/suggest', { text });
async getSuggestion(text: string, cursorPosition?: number): Promise<AssistiveSuggestionResponse> {
const { data } = await aiApiClient.post('/api/writing-assistant/suggest', { text, cursor_position: cursorPosition });
return data;
}
};

View File

@@ -2,6 +2,8 @@
* Service for calling the hallucination detector API endpoints.
*/
import { longRunningApiClient } from '../api/client';
export interface SourceDocument {
title: string;
url: string;
@@ -75,7 +77,6 @@ export interface HealthCheckResponse {
class HallucinationDetectorService {
private baseUrl: string;
private authTokenGetter: (() => Promise<string | null>) | null = null;
constructor() {
const getApiBaseUrl = () => {
@@ -88,19 +89,9 @@ class HallucinationDetectorService {
this.baseUrl = getApiBaseUrl();
}
setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {
this.authTokenGetter = getter;
}
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;
// Kept for backward compatibility — auth is now handled by longRunningApiClient interceptors
setAuthTokenGetter(_getter: (() => Promise<string | null>) | null) {
// no-op
}
/**
@@ -109,28 +100,17 @@ class HallucinationDetectorService {
async detectHallucinations(request: HallucinationDetectionRequest): Promise<HallucinationDetectionResponse> {
console.log('🔍 [HallucinationDetectorService] detectHallucinations called with request:', request);
try {
const url = `${this.baseUrl}/api/hallucination-detector/detect`;
const url = `/api/hallucination-detector/detect`;
console.log('🔍 [HallucinationDetectorService] Making request to:', url);
const response = await fetch(url, {
method: 'POST',
headers: await this.getAuthHeaders(),
body: JSON.stringify(request),
});
const response = await longRunningApiClient.post(url, request);
console.log('🔍 [HallucinationDetectorService] Response status:', response.status, 'OK:', response.ok);
if (!response.ok) {
const errorText = await response.text();
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.log('🔍 [HallucinationDetectorService] Response status:', response.status, 'OK:', response.status === 200);
console.log('🔍 [HallucinationDetectorService] Response data:', response.data);
return response.data;
} catch (error: any) {
console.error('🔍 [HallucinationDetectorService] Error detecting hallucinations:', error);
const errorMessage = error?.response?.data?.error || error?.response?.data?.message || error?.message || 'Unknown error occurred';
return {
success: false,
claims: [],
@@ -140,7 +120,7 @@ class HallucinationDetectorService {
refuted_claims: 0,
insufficient_claims: 0,
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> {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/extract-claims`, {
method: 'POST',
headers: await this.getAuthHeaders(),
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
const response = await longRunningApiClient.post('/api/hallucination-detector/extract-claims', request);
return response.data;
} catch (error: any) {
console.error('Error extracting claims:', error);
return {
success: false,
claims: [],
total_claims: 0,
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> {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/verify-claim`, {
method: 'POST',
headers: await this.getAuthHeaders(),
body: JSON.stringify(request),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
const response = await longRunningApiClient.post('/api/hallucination-detector/verify-claim', request);
return response.data;
} catch (error: any) {
console.error('Error verifying claim:', error);
return {
success: false,
@@ -204,7 +164,7 @@ class HallucinationDetectorService {
reasoning: 'Error during verification'
},
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> {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/health`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
const response = await longRunningApiClient.get('/api/hallucination-detector/health');
return response.data;
} catch (error: any) {
console.error('Error checking health:', error);
return {
status: 'unhealthy',
@@ -239,14 +193,8 @@ class HallucinationDetectorService {
*/
async getDemoInfo(): Promise<any> {
try {
const response = await fetch(`${this.baseUrl}/api/hallucination-detector/demo`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
const response = await longRunningApiClient.get('/api/hallucination-detector/demo');
return response.data;
} catch (error) {
console.error('Error getting demo info:', error);
return null;