Merge main into cursor/implement-usage-based-subscription-and-monitoring-0179
- Resolved merge conflicts in gemini_grounded_provider.py - Removed conflicting Python cache file - Integrated latest LinkedIn Writer features from main branch
This commit is contained in:
295
backend/api/brainstorm.py
Normal file
295
backend/api/brainstorm.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"""
|
||||||
|
Brainstorming endpoints for generating Google search prompts and running a
|
||||||
|
single grounded search to surface topic ideas. Built for reusability across
|
||||||
|
editors. Uses the existing Gemini provider modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from services.llm_providers.gemini_provider import gemini_structured_json_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
from services.llm_providers.gemini_grounded_provider import GeminiGroundedProvider
|
||||||
|
GROUNDED_AVAILABLE = True
|
||||||
|
except Exception:
|
||||||
|
GROUNDED_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/brainstorm", tags=["Brainstorming"])
|
||||||
|
|
||||||
|
|
||||||
|
class PersonaPayload(BaseModel):
|
||||||
|
persona_name: Optional[str] = None
|
||||||
|
archetype: Optional[str] = None
|
||||||
|
core_belief: Optional[str] = None
|
||||||
|
tonal_range: Optional[Dict[str, Any]] = None
|
||||||
|
linguistic_fingerprint: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformPersonaPayload(BaseModel):
|
||||||
|
content_format_rules: Optional[Dict[str, Any]] = None
|
||||||
|
engagement_patterns: Optional[Dict[str, Any]] = None
|
||||||
|
content_types: Optional[Dict[str, Any]] = None
|
||||||
|
tonal_range: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PromptRequest(BaseModel):
|
||||||
|
seed: str = Field(..., description="Idea seed provided by end user")
|
||||||
|
persona: Optional[PersonaPayload] = None
|
||||||
|
platformPersona: Optional[PlatformPersonaPayload] = None
|
||||||
|
count: int = Field(5, ge=3, le=10, description="Number of prompts to generate (default 5)")
|
||||||
|
|
||||||
|
|
||||||
|
class PromptResponse(BaseModel):
|
||||||
|
prompts: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prompts", response_model=PromptResponse)
|
||||||
|
async def generate_prompts(req: PromptRequest) -> PromptResponse:
|
||||||
|
"""Generate N high-signal Google search prompts using Gemini structured output."""
|
||||||
|
try:
|
||||||
|
persona_line = ""
|
||||||
|
if req.persona:
|
||||||
|
parts = []
|
||||||
|
if req.persona.persona_name:
|
||||||
|
parts.append(req.persona.persona_name)
|
||||||
|
if req.persona.archetype:
|
||||||
|
parts.append(f"({req.persona.archetype})")
|
||||||
|
persona_line = " ".join(parts)
|
||||||
|
|
||||||
|
platform_hints = []
|
||||||
|
if req.platformPersona and req.platformPersona.content_format_rules:
|
||||||
|
limit = req.platformPersona.content_format_rules.get("character_limit")
|
||||||
|
if limit:
|
||||||
|
platform_hints.append(f"respect LinkedIn character limit {limit}")
|
||||||
|
|
||||||
|
sys_prompt = (
|
||||||
|
"You are an expert LinkedIn strategist who crafts precise Google search prompts "
|
||||||
|
"to ideate content topics. Follow Google grounding best-practices: be specific, "
|
||||||
|
"time-bound (2024-2025), include entities, and prefer intent-rich phrasing."
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
Seed: {req.seed}
|
||||||
|
Persona: {persona_line or 'N/A'}
|
||||||
|
Guidelines:
|
||||||
|
- Generate {req.count} distinct, high-signal Google search prompts.
|
||||||
|
- Each prompt should include concrete entities (companies, tools, frameworks) when possible.
|
||||||
|
- Prefer phrasing that yields recent, authoritative sources.
|
||||||
|
- Avoid generic phrasing ("latest trends") unless combined with concrete qualifiers.
|
||||||
|
- Optimize for LinkedIn thought leadership and practicality.
|
||||||
|
{('Platform hints: ' + ', '.join(platform_hints)) if platform_hints else ''}
|
||||||
|
|
||||||
|
Return only the list of prompts.
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = gemini_structured_json_response(
|
||||||
|
prompt=prompt,
|
||||||
|
schema=schema,
|
||||||
|
temperature=0.2,
|
||||||
|
top_p=0.9,
|
||||||
|
top_k=40,
|
||||||
|
max_tokens=2048,
|
||||||
|
system_prompt=sys_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
prompts = []
|
||||||
|
if isinstance(result, dict) and isinstance(result.get("prompts"), list):
|
||||||
|
prompts = [str(p).strip() for p in result["prompts"] if str(p).strip()]
|
||||||
|
|
||||||
|
if not prompts:
|
||||||
|
# Minimal fallback: derive simple variations
|
||||||
|
base = req.seed.strip()
|
||||||
|
prompts = [
|
||||||
|
f"Recent data-backed insights about {base}",
|
||||||
|
f"Case studies and benchmarks on {base}",
|
||||||
|
f"Implementation playbooks for {base}",
|
||||||
|
f"Common pitfalls and solutions in {base}",
|
||||||
|
f"Industry leader perspectives on {base}",
|
||||||
|
]
|
||||||
|
|
||||||
|
return PromptResponse(prompts=prompts[: req.count])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating brainstorm prompts: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
prompt: str = Field(..., description="Selected search prompt to run with grounding")
|
||||||
|
max_tokens: int = Field(1024, ge=256, le=4096)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResult(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
snippet: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
results: List[SearchResult] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/search", response_model=SearchResponse)
|
||||||
|
async def run_grounded_search(req: SearchRequest) -> SearchResponse:
|
||||||
|
"""Run a single grounded Google search via GeminiGroundedProvider and return normalized results."""
|
||||||
|
if not GROUNDED_AVAILABLE:
|
||||||
|
raise HTTPException(status_code=503, detail="Grounded provider not available")
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = GeminiGroundedProvider()
|
||||||
|
resp = await provider.generate_grounded_content(
|
||||||
|
prompt=req.prompt,
|
||||||
|
content_type="linkedin_post",
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=req.max_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
items: List[SearchResult] = []
|
||||||
|
# Normalize 'sources' if present
|
||||||
|
for s in (resp.get("sources") or []):
|
||||||
|
items.append(SearchResult(
|
||||||
|
title=s.get("title") or "Source",
|
||||||
|
url=s.get("url") or s.get("link"),
|
||||||
|
snippet=s.get("content") or s.get("snippet")
|
||||||
|
))
|
||||||
|
|
||||||
|
# Provide minimal fallback if no structured sources are returned
|
||||||
|
if not items and resp.get("content"):
|
||||||
|
items.append(SearchResult(title="Generated overview", url=None, snippet=resp.get("content")[:400]))
|
||||||
|
|
||||||
|
return SearchResponse(results=items[:10])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in grounded search: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class IdeasRequest(BaseModel):
|
||||||
|
seed: str
|
||||||
|
persona: Optional[PersonaPayload] = None
|
||||||
|
platformPersona: Optional[PlatformPersonaPayload] = None
|
||||||
|
results: List[SearchResult] = []
|
||||||
|
count: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class IdeaItem(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
rationale: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class IdeasResponse(BaseModel):
|
||||||
|
ideas: List[IdeaItem]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ideas", response_model=IdeasResponse)
|
||||||
|
async def generate_brainstorm_ideas(req: IdeasRequest) -> IdeasResponse:
|
||||||
|
"""
|
||||||
|
Create brainstorm ideas by combining persona, seed, and Google search results.
|
||||||
|
Uses gemini_structured_json_response for consistent output.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Build compact search context
|
||||||
|
top_results = req.results[:5]
|
||||||
|
sources_block = "\n".join(
|
||||||
|
[
|
||||||
|
f"- {r.title or 'Source'} | {r.url or ''} | {r.snippet or ''}"
|
||||||
|
for r in top_results
|
||||||
|
]
|
||||||
|
) or "(no sources)"
|
||||||
|
|
||||||
|
persona_block = ""
|
||||||
|
if req.persona:
|
||||||
|
persona_block = (
|
||||||
|
f"Persona: {req.persona.persona_name or ''} {('(' + req.persona.archetype + ')') if req.persona.archetype else ''}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_block = ""
|
||||||
|
if req.platformPersona and req.platformPersona.content_format_rules:
|
||||||
|
limit = req.platformPersona.content_format_rules.get("character_limit")
|
||||||
|
platform_block = f"LinkedIn character limit: {limit}" if limit else ""
|
||||||
|
|
||||||
|
sys_prompt = (
|
||||||
|
"You are an enterprise-grade LinkedIn strategist. Generate specific, non-generic "
|
||||||
|
"brainstorm prompts suitable for LinkedIn posts or carousels. Use the provided web "
|
||||||
|
"sources to ground ideas and the persona to align tone and style."
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""
|
||||||
|
SEED IDEA: {req.seed}
|
||||||
|
{persona_block}
|
||||||
|
{platform_block}
|
||||||
|
|
||||||
|
RECENT WEB SOURCES (top {len(top_results)}):
|
||||||
|
{sources_block}
|
||||||
|
|
||||||
|
TASK:
|
||||||
|
- Propose {req.count} LinkedIn-ready brainstorm prompts tailored to the persona and grounded in the sources.
|
||||||
|
- Each prompt should be specific and actionable for 2024–2025.
|
||||||
|
- Prefer thought-leadership angles, contrarian takes with evidence, or practical playbooks.
|
||||||
|
- Avoid generic phrases like "latest trends" unless qualified by entities.
|
||||||
|
|
||||||
|
Return JSON with an array named ideas where each item has:
|
||||||
|
- prompt: the exact text the user can use to generate a post
|
||||||
|
- rationale: 1–2 sentence why this works for the audience/persona
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ideas": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"prompt": {"type": "string"},
|
||||||
|
"rationale": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = gemini_structured_json_response(
|
||||||
|
prompt=prompt,
|
||||||
|
schema=schema,
|
||||||
|
temperature=0.2,
|
||||||
|
top_p=0.9,
|
||||||
|
top_k=40,
|
||||||
|
max_tokens=2048,
|
||||||
|
system_prompt=sys_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
ideas: List[IdeaItem] = []
|
||||||
|
if isinstance(result, dict) and isinstance(result.get("ideas"), list):
|
||||||
|
for item in result["ideas"]:
|
||||||
|
if isinstance(item, dict) and item.get("prompt"):
|
||||||
|
ideas.append(IdeaItem(prompt=item["prompt"], rationale=item.get("rationale")))
|
||||||
|
|
||||||
|
if not ideas:
|
||||||
|
# Fallback basic ideas from seed if model returns nothing
|
||||||
|
ideas = [
|
||||||
|
IdeaItem(prompt=f"Explain why {req.seed} matters now with 2 recent stats", rationale="Timely and data-backed."),
|
||||||
|
IdeaItem(prompt=f"Common pitfalls in {req.seed} and how to avoid them", rationale="Actionable and experience-based."),
|
||||||
|
IdeaItem(prompt=f"A step-by-step playbook to implement {req.seed}", rationale="Practical value."),
|
||||||
|
IdeaItem(prompt=f"Case study: measurable impact of {req.seed}", rationale="Story + ROI."),
|
||||||
|
IdeaItem(prompt=f"Contrarian take: what most get wrong about {req.seed}", rationale="Thought leadership.")
|
||||||
|
]
|
||||||
|
|
||||||
|
return IdeasResponse(ideas=ideas[: req.count])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating brainstorm ideas: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -262,6 +262,75 @@ async def delete_persona(user_id: int, persona_id: int):
|
|||||||
logger.error(f"Error deleting persona: {str(e)}")
|
logger.error(f"Error deleting persona: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to delete persona: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to delete persona: {str(e)}")
|
||||||
|
|
||||||
|
async def update_platform_persona(user_id: int, platform: str, update_data: Dict[str, Any]):
|
||||||
|
"""Update platform-specific persona fields for a user's persona.
|
||||||
|
|
||||||
|
This updates the underlying PlatformPersona row for the given platform.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from services.database import get_db_session
|
||||||
|
from models.persona_models import WritingPersona, PlatformPersona
|
||||||
|
|
||||||
|
session = get_db_session()
|
||||||
|
|
||||||
|
# Find the user's active core persona id
|
||||||
|
core_persona = session.query(WritingPersona).filter(
|
||||||
|
WritingPersona.user_id == user_id,
|
||||||
|
WritingPersona.is_active == True
|
||||||
|
).order_by(WritingPersona.created_at.desc()).first()
|
||||||
|
|
||||||
|
if not core_persona:
|
||||||
|
raise HTTPException(status_code=404, detail="No active persona found for user")
|
||||||
|
|
||||||
|
# Find the platform persona for the requested platform
|
||||||
|
platform_persona = session.query(PlatformPersona).filter(
|
||||||
|
PlatformPersona.writing_persona_id == core_persona.id,
|
||||||
|
PlatformPersona.platform_type.ilike(platform),
|
||||||
|
PlatformPersona.is_active == True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not platform_persona:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No platform persona found for platform {platform}")
|
||||||
|
|
||||||
|
# Update allowed platform fields
|
||||||
|
updatable_fields = [
|
||||||
|
'sentence_metrics', 'lexical_features', 'rhetorical_devices', 'tonal_range',
|
||||||
|
'stylistic_constraints', 'content_format_rules', 'engagement_patterns',
|
||||||
|
'posting_frequency', 'content_types', 'platform_best_practices', 'algorithm_considerations'
|
||||||
|
]
|
||||||
|
|
||||||
|
updated_any = False
|
||||||
|
for field in updatable_fields:
|
||||||
|
if field in update_data:
|
||||||
|
setattr(platform_persona, field, update_data[field])
|
||||||
|
updated_any = True
|
||||||
|
|
||||||
|
if not updated_any:
|
||||||
|
# Nothing to update
|
||||||
|
session.close()
|
||||||
|
return {
|
||||||
|
"message": "No updatable fields provided",
|
||||||
|
"platform": platform_persona.platform_type,
|
||||||
|
"persona_id": core_persona.id
|
||||||
|
}
|
||||||
|
|
||||||
|
platform_persona.updated_at = datetime.utcnow()
|
||||||
|
session.commit()
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Platform persona updated successfully",
|
||||||
|
"platform": platform,
|
||||||
|
"persona_id": core_persona.id,
|
||||||
|
"updated_at": platform_persona.updated_at.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating platform persona: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update platform persona: {str(e)}")
|
||||||
|
|
||||||
async def validate_persona_generation_readiness(user_id: int):
|
async def validate_persona_generation_readiness(user_id: int):
|
||||||
"""Check if user has sufficient onboarding data for persona generation."""
|
"""Check if user has sufficient onboarding data for persona generation."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from api.persona import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from services.persona_replication_engine import PersonaReplicationEngine
|
from services.persona_replication_engine import PersonaReplicationEngine
|
||||||
|
from api.persona import update_platform_persona
|
||||||
|
|
||||||
# Create router
|
# Create router
|
||||||
router = APIRouter(prefix="/api/personas", tags=["personas"])
|
router = APIRouter(prefix="/api/personas", tags=["personas"])
|
||||||
@@ -205,3 +206,15 @@ async def validate_content_endpoint(
|
|||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
|
||||||
|
|
||||||
|
@router.put("/platform/{platform}")
|
||||||
|
async def update_platform_persona_endpoint(
|
||||||
|
platform: str,
|
||||||
|
update_data: Dict[str, Any],
|
||||||
|
user_id: int = Query(1, description="User ID")
|
||||||
|
):
|
||||||
|
"""Update platform-specific persona fields for a user.
|
||||||
|
|
||||||
|
Allows editing persona fields in the UI and saving them to the database.
|
||||||
|
"""
|
||||||
|
return await update_platform_persona(user_id, platform, update_data)
|
||||||
@@ -60,6 +60,7 @@ from api.facebook_writer.routers import facebook_router
|
|||||||
from routers.linkedin import router as linkedin_router
|
from routers.linkedin import router as linkedin_router
|
||||||
# Import LinkedIn image generation router
|
# Import LinkedIn image generation router
|
||||||
from api.linkedin_image_generation import router as linkedin_image_router
|
from api.linkedin_image_generation import router as linkedin_image_router
|
||||||
|
from api.brainstorm import router as brainstorm_router
|
||||||
|
|
||||||
# Import hallucination detector router
|
# Import hallucination detector router
|
||||||
from api.hallucination_detector import router as hallucination_detector_router
|
from api.hallucination_detector import router as hallucination_detector_router
|
||||||
@@ -446,6 +447,7 @@ app.include_router(facebook_router)
|
|||||||
app.include_router(linkedin_router)
|
app.include_router(linkedin_router)
|
||||||
# Include LinkedIn image generation router
|
# Include LinkedIn image generation router
|
||||||
app.include_router(linkedin_image_router)
|
app.include_router(linkedin_image_router)
|
||||||
|
app.include_router(brainstorm_router)
|
||||||
|
|
||||||
# Include hallucination detector router
|
# Include hallucination detector router
|
||||||
app.include_router(hallucination_detector_router)
|
app.include_router(hallucination_detector_router)
|
||||||
|
|||||||
Binary file not shown.
@@ -61,6 +61,8 @@ class LinkedInPostRequest(BaseModel):
|
|||||||
max_length: int = Field(default=3000, description="Maximum character count", ge=100, le=3000)
|
max_length: int = Field(default=3000, description="Maximum character count", ge=100, le=3000)
|
||||||
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
||||||
|
user_id: Optional[int] = Field(default=1, description="User id for persona lookup")
|
||||||
|
persona_override: Optional[Dict[str, Any]] = Field(default=None, description="Session-only persona overrides to apply without saving")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
schema_extra = {
|
schema_extra = {
|
||||||
@@ -96,6 +98,8 @@ class LinkedInArticleRequest(BaseModel):
|
|||||||
word_count: int = Field(default=1500, description="Target word count", ge=500, le=5000)
|
word_count: int = Field(default=1500, description="Target word count", ge=500, le=5000)
|
||||||
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
grounding_level: GroundingLevel = Field(default=GroundingLevel.ENHANCED, description="Level of content grounding")
|
||||||
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
include_citations: bool = Field(default=True, description="Whether to include inline citations")
|
||||||
|
user_id: Optional[int] = Field(default=1, description="User id for persona lookup")
|
||||||
|
persona_override: Optional[Dict[str, Any]] = Field(default=None, description="Session-only persona overrides to apply without saving")
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
schema_extra = {
|
schema_extra = {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from services.linkedin.content_generator_prompts import (
|
|||||||
CarouselGenerator,
|
CarouselGenerator,
|
||||||
VideoScriptGenerator
|
VideoScriptGenerator
|
||||||
)
|
)
|
||||||
|
from services.persona_analysis_service import PersonaAnalysisService
|
||||||
|
|
||||||
|
|
||||||
class ContentGenerator:
|
class ContentGenerator:
|
||||||
@@ -340,8 +341,27 @@ class ContentGenerator:
|
|||||||
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
||||||
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
||||||
|
|
||||||
# Build the prompt for grounded generation using the new prompt builder
|
# Build the prompt for grounded generation using persona if available (DB vs session override)
|
||||||
prompt = PostPromptBuilder.build_post_prompt(request)
|
persona_service = PersonaAnalysisService()
|
||||||
|
persona_data = persona_service.get_persona_for_platform(user_id=getattr(request, 'user_id', 1), platform='linkedin') if hasattr(request, 'user_id') else None
|
||||||
|
if getattr(request, 'persona_override', None):
|
||||||
|
try:
|
||||||
|
# Merge shallowly: override core and platform adaptation parts
|
||||||
|
override = request.persona_override
|
||||||
|
if persona_data:
|
||||||
|
core = persona_data.get('core_persona', {})
|
||||||
|
platform_adapt = persona_data.get('platform_adaptation', {})
|
||||||
|
if 'core_persona' in override:
|
||||||
|
core.update(override['core_persona'])
|
||||||
|
if 'platform_adaptation' in override:
|
||||||
|
platform_adapt.update(override['platform_adaptation'])
|
||||||
|
persona_data['core_persona'] = core
|
||||||
|
persona_data['platform_adaptation'] = platform_adapt
|
||||||
|
else:
|
||||||
|
persona_data = override
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
prompt = PostPromptBuilder.build_post_prompt(request, persona=persona_data)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Generate grounded content using native Google Search grounding
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
result = await self.gemini_grounded.generate_grounded_content(
|
||||||
@@ -395,8 +415,26 @@ class ContentGenerator:
|
|||||||
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
logger.error("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
||||||
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
raise Exception("Gemini Grounded Provider not available - cannot generate content without AI provider")
|
||||||
|
|
||||||
# Build the prompt for grounded generation using the new prompt builder
|
# Build the prompt for grounded generation using persona if available (DB vs session override)
|
||||||
prompt = ArticlePromptBuilder.build_article_prompt(request)
|
persona_service = PersonaAnalysisService()
|
||||||
|
persona_data = persona_service.get_persona_for_platform(user_id=getattr(request, 'user_id', 1), platform='linkedin') if hasattr(request, 'user_id') else None
|
||||||
|
if getattr(request, 'persona_override', None):
|
||||||
|
try:
|
||||||
|
override = request.persona_override
|
||||||
|
if persona_data:
|
||||||
|
core = persona_data.get('core_persona', {})
|
||||||
|
platform_adapt = persona_data.get('platform_adaptation', {})
|
||||||
|
if 'core_persona' in override:
|
||||||
|
core.update(override['core_persona'])
|
||||||
|
if 'platform_adaptation' in override:
|
||||||
|
platform_adapt.update(override['platform_adaptation'])
|
||||||
|
persona_data['core_persona'] = core
|
||||||
|
persona_data['platform_adaptation'] = platform_adapt
|
||||||
|
else:
|
||||||
|
persona_data = override
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
prompt = ArticlePromptBuilder.build_article_prompt(request, persona=persona_data)
|
||||||
|
|
||||||
# Generate grounded content using native Google Search grounding
|
# Generate grounded content using native Google Search grounding
|
||||||
result = await self.gemini_grounded.generate_grounded_content(
|
result = await self.gemini_grounded.generate_grounded_content(
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ LinkedIn Article Generation Prompts
|
|||||||
This module contains prompt templates and builders for generating LinkedIn articles.
|
This module contains prompt templates and builders for generating LinkedIn articles.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Optional, Dict
|
||||||
|
|
||||||
|
|
||||||
class ArticlePromptBuilder:
|
class ArticlePromptBuilder:
|
||||||
"""Builder class for LinkedIn article generation prompts."""
|
"""Builder class for LinkedIn article generation prompts."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_article_prompt(request: Any) -> str:
|
def build_article_prompt(request: Any, persona: Optional[Dict[str, Any]] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Build prompt for article generation.
|
Build prompt for article generation.
|
||||||
|
|
||||||
@@ -21,6 +21,27 @@ class ArticlePromptBuilder:
|
|||||||
Returns:
|
Returns:
|
||||||
Formatted prompt string for article generation
|
Formatted prompt string for article generation
|
||||||
"""
|
"""
|
||||||
|
persona_block = ""
|
||||||
|
if persona:
|
||||||
|
try:
|
||||||
|
core = persona.get('core_persona', persona)
|
||||||
|
platform_adaptation = persona.get('platform_adaptation', persona.get('platform_persona', {}))
|
||||||
|
linguistic = core.get('linguistic_fingerprint', {})
|
||||||
|
sentence_metrics = linguistic.get('sentence_metrics', {})
|
||||||
|
lexical_features = linguistic.get('lexical_features', {})
|
||||||
|
tonal_range = core.get('tonal_range', {})
|
||||||
|
persona_block = f"""
|
||||||
|
PERSONA CONTEXT:
|
||||||
|
- Persona Name: {core.get('persona_name', 'N/A')}
|
||||||
|
- Archetype: {core.get('archetype', 'N/A')}
|
||||||
|
- Core Belief: {core.get('core_belief', 'N/A')}
|
||||||
|
- Default Tone: {tonal_range.get('default_tone', request.tone)}
|
||||||
|
- Avg Sentence Length: {sentence_metrics.get('average_sentence_length_words', 18)} words
|
||||||
|
- Go-to Words: {', '.join(lexical_features.get('go_to_words', [])[:5])}
|
||||||
|
""".rstrip()
|
||||||
|
except Exception:
|
||||||
|
persona_block = ""
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
You are a senior content strategist and industry expert specializing in {request.industry}. Create a comprehensive, thought-provoking LinkedIn article that establishes authority, drives engagement, and provides genuine value to professionals in this field.
|
You are a senior content strategist and industry expert specializing in {request.industry}. Create a comprehensive, thought-provoking LinkedIn article that establishes authority, drives engagement, and provides genuine value to professionals in this field.
|
||||||
|
|
||||||
@@ -30,6 +51,8 @@ class ArticlePromptBuilder:
|
|||||||
TARGET AUDIENCE: {request.target_audience or 'Industry professionals, executives, and thought leaders'}
|
TARGET AUDIENCE: {request.target_audience or 'Industry professionals, executives, and thought leaders'}
|
||||||
WORD COUNT: {request.word_count} words
|
WORD COUNT: {request.word_count} words
|
||||||
|
|
||||||
|
{persona_block}
|
||||||
|
|
||||||
CONTENT STRUCTURE:
|
CONTENT STRUCTURE:
|
||||||
- Compelling headline that promises specific value
|
- Compelling headline that promises specific value
|
||||||
- Engaging introduction with a hook and clear value proposition
|
- Engaging introduction with a hook and clear value proposition
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ LinkedIn Post Generation Prompts
|
|||||||
This module contains prompt templates and builders for generating LinkedIn posts.
|
This module contains prompt templates and builders for generating LinkedIn posts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Optional, Dict
|
||||||
|
|
||||||
|
|
||||||
class PostPromptBuilder:
|
class PostPromptBuilder:
|
||||||
"""Builder class for LinkedIn post generation prompts."""
|
"""Builder class for LinkedIn post generation prompts."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_post_prompt(request: Any) -> str:
|
def build_post_prompt(request: Any, persona: Optional[Dict[str, Any]] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Build prompt for post generation.
|
Build prompt for post generation.
|
||||||
|
|
||||||
@@ -21,6 +21,33 @@ class PostPromptBuilder:
|
|||||||
Returns:
|
Returns:
|
||||||
Formatted prompt string for post generation
|
Formatted prompt string for post generation
|
||||||
"""
|
"""
|
||||||
|
persona_block = ""
|
||||||
|
if persona:
|
||||||
|
try:
|
||||||
|
# Expecting structure similar to persona_service.get_persona_for_platform output
|
||||||
|
core = persona.get('core_persona', persona)
|
||||||
|
platform_adaptation = persona.get('platform_adaptation', persona.get('platform_persona', {}))
|
||||||
|
linguistic = core.get('linguistic_fingerprint', {})
|
||||||
|
sentence_metrics = linguistic.get('sentence_metrics', {})
|
||||||
|
lexical_features = linguistic.get('lexical_features', {})
|
||||||
|
rhetorical_devices = linguistic.get('rhetorical_devices', {})
|
||||||
|
tonal_range = core.get('tonal_range', {})
|
||||||
|
|
||||||
|
persona_block = f"""
|
||||||
|
PERSONA CONTEXT:
|
||||||
|
- Persona Name: {core.get('persona_name', 'N/A')}
|
||||||
|
- Archetype: {core.get('archetype', 'N/A')}
|
||||||
|
- Core Belief: {core.get('core_belief', 'N/A')}
|
||||||
|
- Tone: {tonal_range.get('default_tone', request.tone)}
|
||||||
|
- Sentence Length (avg): {sentence_metrics.get('average_sentence_length_words', 15)} words
|
||||||
|
- Preferred Sentence Type: {sentence_metrics.get('preferred_sentence_type', 'simple and compound')}
|
||||||
|
- Go-to Words: {', '.join(lexical_features.get('go_to_words', [])[:5])}
|
||||||
|
- Avoid Words: {', '.join(lexical_features.get('avoid_words', [])[:5])}
|
||||||
|
- Rhetorical Style: {rhetorical_devices.get('summary','balanced rhetorical questions and examples')}
|
||||||
|
""".rstrip()
|
||||||
|
except Exception:
|
||||||
|
persona_block = ""
|
||||||
|
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
You are an expert LinkedIn content strategist with 10+ years of experience in the {request.industry} industry. Create a highly engaging, professional LinkedIn post that drives meaningful engagement and establishes thought leadership.
|
You are an expert LinkedIn content strategist with 10+ years of experience in the {request.industry} industry. Create a highly engaging, professional LinkedIn post that drives meaningful engagement and establishes thought leadership.
|
||||||
|
|
||||||
@@ -30,6 +57,8 @@ class PostPromptBuilder:
|
|||||||
TARGET AUDIENCE: {request.target_audience or 'Industry professionals, decision-makers, and thought leaders'}
|
TARGET AUDIENCE: {request.target_audience or 'Industry professionals, decision-makers, and thought leaders'}
|
||||||
MAX LENGTH: {request.max_length} characters
|
MAX LENGTH: {request.max_length} characters
|
||||||
|
|
||||||
|
{persona_block}
|
||||||
|
|
||||||
CONTENT REQUIREMENTS:
|
CONTENT REQUIREMENTS:
|
||||||
- Start with a compelling hook that addresses a pain point or opportunity
|
- Start with a compelling hook that addresses a pain point or opportunity
|
||||||
- Include 2-3 specific, actionable insights or data points
|
- Include 2-3 specific, actionable insights or data points
|
||||||
|
|||||||
@@ -396,9 +396,7 @@ class GeminiGroundedProvider:
|
|||||||
logger.error(f"First candidate structure: {dir(candidates[0]) if candidates else 'No candidates'}")
|
logger.error(f"First candidate structure: {dir(candidates[0]) if candidates else 'No candidates'}")
|
||||||
raise ValueError("No grounding metadata found - grounding is not working properly")
|
raise ValueError("No grounding metadata found - grounding is not working properly")
|
||||||
else:
|
else:
|
||||||
logger.error("❌ CRITICAL: No candidates found in response")
|
logger.warning("⚠️ No candidates found in response. Returning content without sources.")
|
||||||
logger.error(f"Response structure: {dir(response)}")
|
|
||||||
raise ValueError("No candidates found in response - grounding is not working properly")
|
|
||||||
|
|
||||||
# Add content-specific processing
|
# Add content-specific processing
|
||||||
if content_type == "linkedin_post":
|
if content_type == "linkedin_post":
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import './styles/alwrity-copilot.css';
|
|||||||
import RegisterLinkedInActions from './RegisterLinkedInActions';
|
import RegisterLinkedInActions from './RegisterLinkedInActions';
|
||||||
import RegisterLinkedInEditActions from './RegisterLinkedInEditActions';
|
import RegisterLinkedInEditActions from './RegisterLinkedInEditActions';
|
||||||
import RegisterLinkedInActionsEnhanced from './RegisterLinkedInActionsEnhanced';
|
import RegisterLinkedInActionsEnhanced from './RegisterLinkedInActionsEnhanced';
|
||||||
import { Header, ContentEditor, LoadingIndicator, WelcomeMessage, ProgressTracker } from './components';
|
import { Header, ContentEditor, LoadingIndicator, WelcomeMessage, ProgressTracker, CopilotActions } from './components';
|
||||||
import { useLinkedInWriter } from './hooks/useLinkedInWriter';
|
import { useLinkedInWriter } from './hooks/useLinkedInWriter';
|
||||||
import { useCopilotPersistence } from './utils/enhancedPersistence';
|
import { useCopilotPersistence } from './utils/enhancedPersistence';
|
||||||
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
@@ -83,11 +83,9 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
// Get persona context for enhanced AI assistance
|
// Get persona context for enhanced AI assistance
|
||||||
const { corePersona, platformPersona, loading: personaLoading } = usePlatformPersonaContext();
|
const { corePersona, platformPersona, loading: personaLoading } = usePlatformPersonaContext();
|
||||||
|
|
||||||
|
|
||||||
// Get enhanced persistence functionality
|
// Get enhanced persistence functionality
|
||||||
const {
|
const {
|
||||||
persistenceManager,
|
persistenceManager,
|
||||||
copilotContext,
|
|
||||||
saveChatHistory,
|
saveChatHistory,
|
||||||
loadChatHistory,
|
loadChatHistory,
|
||||||
addChatMessage,
|
addChatMessage,
|
||||||
@@ -227,171 +225,17 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow Copilot to edit the draft with specific operations
|
|
||||||
useCopilotActionTyped({
|
|
||||||
name: 'editLinkedInDraft',
|
|
||||||
description: 'Apply a quick style or structural edit to the current LinkedIn draft',
|
|
||||||
parameters: [
|
|
||||||
{ name: 'operation', type: 'string', description: 'The edit operation to perform', required: true, enum: ['Casual', 'Professional', 'TightenHook', 'AddCTA', 'Shorten', 'Lengthen'] }
|
|
||||||
],
|
|
||||||
handler: async ({ operation }: { operation: string }) => {
|
|
||||||
const currentDraft = draft || '';
|
|
||||||
if (!currentDraft) {
|
|
||||||
return { success: false, message: 'No draft content to edit' };
|
|
||||||
}
|
|
||||||
|
|
||||||
let editedContent = currentDraft;
|
// Initialize CopilotActions component to handle all copilot-related functionality
|
||||||
|
const getIntelligentSuggestions = CopilotActions({
|
||||||
switch (operation) {
|
draft,
|
||||||
case 'Casual':
|
context,
|
||||||
editedContent = currentDraft.replace(/\b(utilize|implement|facilitate|leverage)\b/gi, (match) => {
|
userPreferences,
|
||||||
const casual = { utilize: 'use', implement: 'put in place', facilitate: 'help', leverage: 'use' };
|
justGeneratedContent,
|
||||||
return casual[match.toLowerCase() as keyof typeof casual] || match;
|
handleContextChange,
|
||||||
});
|
setDraft
|
||||||
editedContent = editedContent.replace(/\./g, '! 😊');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Professional':
|
|
||||||
editedContent = currentDraft.replace(/\b(use|put in place|help)\b/gi, (match) => {
|
|
||||||
const professional = { use: 'utilize', 'put in place': 'implement', help: 'facilitate' };
|
|
||||||
return professional[match.toLowerCase() as keyof typeof professional] || match;
|
|
||||||
});
|
|
||||||
editedContent = editedContent.replace(/! 😊/g, '.');
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'TightenHook':
|
|
||||||
const lines = currentDraft.split('\n');
|
|
||||||
if (lines.length > 0) {
|
|
||||||
const firstLine = lines[0];
|
|
||||||
const tightened = firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine;
|
|
||||||
lines[0] = tightened;
|
|
||||||
editedContent = lines.join('\n');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'AddCTA':
|
|
||||||
if (!/\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(currentDraft)) {
|
|
||||||
editedContent = currentDraft + '\n\nWhat are your thoughts on this? Share your experience in the comments below!';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Shorten':
|
|
||||||
if (currentDraft.length > 200) {
|
|
||||||
editedContent = currentDraft.substring(0, 200) + '...';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'Lengthen':
|
|
||||||
if (currentDraft.length < 500) {
|
|
||||||
editedContent = currentDraft + '\n\nThis approach has shown remarkable results in our industry. The key is to maintain consistency while adapting to changing market conditions.';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return { success: false, message: 'Unknown operation' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the edit action to show the diff preview
|
|
||||||
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', {
|
|
||||||
detail: { target: editedContent }
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { success: true, message: `Draft ${operation.toLowerCase()} applied`, content: editedContent };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Intelligent, stage-aware suggestions (memoized to prevent infinite re-rendering)
|
|
||||||
const getIntelligentSuggestions = useMemo(() => {
|
|
||||||
const hasContent = draft && draft.trim().length > 0;
|
|
||||||
const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(draft || '');
|
|
||||||
const hasHashtags = /#[A-Za-z0-9_]+/.test(draft || '');
|
|
||||||
const isLong = (draft || '').length > 500;
|
|
||||||
|
|
||||||
// Debug logging for suggestions
|
|
||||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
|
|
||||||
hasContent,
|
|
||||||
justGeneratedContent,
|
|
||||||
draftLength: draft?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasContent) {
|
|
||||||
// Initial suggestions for content creation
|
|
||||||
const initialSuggestions = [
|
|
||||||
{ title: '📝 LinkedIn Post', message: 'Use tool generateLinkedInPost to create a professional LinkedIn post for your industry.' },
|
|
||||||
{ title: '📄 Article', message: 'Use tool generateLinkedInArticle to write a thought leadership article.' },
|
|
||||||
{ title: '🎠 Carousel', message: 'Use tool generateLinkedInCarousel to create a multi-slide carousel presentation.' },
|
|
||||||
{ title: '🎬 Video Script', message: 'Use tool generateLinkedInVideoScript to draft a video script for LinkedIn.' },
|
|
||||||
{ title: '💬 Comment Response', message: 'Use tool generateLinkedInCommentResponse to craft a professional comment reply.' },
|
|
||||||
{ title: '🖼️ Generate Post Image', message: 'Use tool generateLinkedInImagePrompts to create professional images for your LinkedIn content.' },
|
|
||||||
{ title: '🎨 Visual Content', message: 'Create engaging visual content with AI-generated images optimized for LinkedIn.' }
|
|
||||||
];
|
|
||||||
console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions);
|
|
||||||
return initialSuggestions;
|
|
||||||
} else {
|
|
||||||
// Refinement suggestions for existing content - use direct edit actions
|
|
||||||
const refinementSuggestions = [
|
|
||||||
{ title: '🙂 Make it casual', message: 'Use tool editLinkedInDraft with operation Casual' },
|
|
||||||
{ title: '💼 Make it professional', message: 'Use tool editLinkedInDraft with operation Professional' },
|
|
||||||
{ title: '✨ Tighten hook', message: 'Use tool editLinkedInDraft with operation TightenHook' },
|
|
||||||
{ title: '📣 Add a CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' },
|
|
||||||
{ title: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' },
|
|
||||||
{ title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add special suggestions when content was just generated
|
|
||||||
if (justGeneratedContent) {
|
|
||||||
console.log('[LinkedIn Writer] Adding post-generation suggestions');
|
|
||||||
refinementSuggestions.unshift(
|
|
||||||
{
|
|
||||||
title: '🎉 Content Generated! Next Steps:',
|
|
||||||
message: 'Great! Your content is ready. Now let\'s enhance it with images and make it perfect for LinkedIn.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '🖼️ Generate Post Image',
|
|
||||||
message: 'Use tool generateLinkedInImagePrompts to create professional images for this LinkedIn post'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add contextual suggestions based on content analysis
|
|
||||||
if (!hasCTA) {
|
|
||||||
refinementSuggestions.push({ title: '📣 Add CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' });
|
|
||||||
}
|
|
||||||
if (!hasHashtags) {
|
|
||||||
refinementSuggestions.push({ title: '🏷️ Add hashtags', message: 'Use tool addLinkedInHashtags' });
|
|
||||||
}
|
|
||||||
if (isLong) {
|
|
||||||
refinementSuggestions.push({ title: '📝 Summarize intro', message: 'Use tool editLinkedInDraft with operation Shorten' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add image generation suggestion when there's content
|
|
||||||
if (draft && draft.trim().length > 0) {
|
|
||||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Adding image generation suggestion');
|
|
||||||
// Make image generation suggestion more prominent
|
|
||||||
refinementSuggestions.push({
|
|
||||||
title: '🖼️ Generate Post Image',
|
|
||||||
message: 'Use tool generateLinkedInImagePrompts to create professional images for this LinkedIn post'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add contextual image suggestions based on content type
|
|
||||||
if (draft.includes('digital transformation') || draft.includes('technology') || draft.includes('innovation')) {
|
|
||||||
refinementSuggestions.push({
|
|
||||||
title: '🚀 Tech-Focused Image',
|
|
||||||
message: 'Use tool generateLinkedInImagePrompts to create technology-themed professional images for this post'
|
|
||||||
});
|
|
||||||
} else if (draft.includes('business') || draft.includes('strategy') || draft.includes('growth')) {
|
|
||||||
refinementSuggestions.push({
|
|
||||||
title: '💼 Business Image',
|
|
||||||
message: 'Use tool generateLinkedInImagePrompts to create business-focused professional images for this post'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
|
|
||||||
return refinementSuggestions;
|
|
||||||
}
|
|
||||||
}, [draft, justGeneratedContent]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`linkedin-writer ${className}`} style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
<div className={`linkedin-writer ${className}`} style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -418,6 +262,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Debug: Enhanced Persistence Test Buttons (remove in production) */}
|
{/* Debug: Enhanced Persistence Test Buttons (remove in production) */}
|
||||||
|
|
||||||
|
|
||||||
@@ -470,6 +315,7 @@ const LinkedInWriterContent: React.FC<LinkedInWriterProps> = ({ className = '' }
|
|||||||
{/* Enhanced Persona-Aware Actions */}
|
{/* Enhanced Persona-Aware Actions */}
|
||||||
<RegisterLinkedInActionsEnhanced />
|
<RegisterLinkedInActionsEnhanced />
|
||||||
|
|
||||||
|
|
||||||
{/* CopilotKit Sidebar */}
|
{/* CopilotKit Sidebar */}
|
||||||
<CopilotSidebar
|
<CopilotSidebar
|
||||||
className="alwrity-copilot-sidebar linkedin-writer"
|
className="alwrity-copilot-sidebar linkedin-writer"
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
|
||||||
|
// Define the cache data type
|
||||||
|
interface BrainstormCacheData {
|
||||||
|
ideas: { prompt: string; rationale?: string }[];
|
||||||
|
searchResults: any[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guard function
|
||||||
|
const isBrainstormCacheData = (data: any): data is BrainstormCacheData => {
|
||||||
|
return data &&
|
||||||
|
Array.isArray(data.ideas) &&
|
||||||
|
Array.isArray(data.searchResults) &&
|
||||||
|
typeof data.timestamp === 'number';
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BrainstormFlowProps {
|
||||||
|
brainstormVisible: boolean;
|
||||||
|
setBrainstormVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
brainstormStage: 'loading' | 'select' | 'results';
|
||||||
|
setBrainstormStage: React.Dispatch<React.SetStateAction<'loading' | 'select' | 'results'>>;
|
||||||
|
loaderMessageIndex: number;
|
||||||
|
setLoaderMessageIndex: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
aiSearchPrompts: string[];
|
||||||
|
setAiSearchPrompts: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
selectedPrompt: string;
|
||||||
|
setSelectedPrompt: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
searchResults: any[];
|
||||||
|
setSearchResults: React.Dispatch<React.SetStateAction<any[]>>;
|
||||||
|
ideas: { prompt: string; rationale?: string }[];
|
||||||
|
setIdeas: React.Dispatch<React.SetStateAction<{ prompt: string; rationale?: string }[]>>;
|
||||||
|
isUsingCache: boolean;
|
||||||
|
setIsUsingCache: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrainstormFlow: React.FC<BrainstormFlowProps> = ({
|
||||||
|
brainstormVisible,
|
||||||
|
setBrainstormVisible,
|
||||||
|
brainstormStage,
|
||||||
|
setBrainstormStage,
|
||||||
|
loaderMessageIndex,
|
||||||
|
setLoaderMessageIndex,
|
||||||
|
aiSearchPrompts,
|
||||||
|
setAiSearchPrompts,
|
||||||
|
selectedPrompt,
|
||||||
|
setSelectedPrompt,
|
||||||
|
searchResults,
|
||||||
|
setSearchResults,
|
||||||
|
ideas,
|
||||||
|
setIdeas,
|
||||||
|
isUsingCache,
|
||||||
|
setIsUsingCache
|
||||||
|
}) => {
|
||||||
|
const { corePersona, platformPersona } = usePlatformPersonaContext();
|
||||||
|
|
||||||
|
const loaderMessages = useMemo(() => ([
|
||||||
|
'Searching the web for the most recent and relevant coverage...',
|
||||||
|
'Extracting entities and context from top sources...',
|
||||||
|
'Aligning findings with your persona and audience...',
|
||||||
|
'Formulating high-signal brainstorm prompts you can use right away...'
|
||||||
|
]), []);
|
||||||
|
|
||||||
|
// Cache management utilities
|
||||||
|
const getCacheKey = useCallback((seed: string, personaId?: string, platformPersonaId?: string) => {
|
||||||
|
return `brainstorm_ideas_${seed}_${personaId || 'default'}_${platformPersonaId || 'default'}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCachedIdeas = useCallback((cacheKey: string): BrainstormCacheData | null => {
|
||||||
|
try {
|
||||||
|
const cached = sessionStorage.getItem(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached);
|
||||||
|
if (isBrainstormCacheData(data)) {
|
||||||
|
// Check if cache is less than 1 hour old
|
||||||
|
if (Date.now() - data.timestamp < 3600000) {
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to read brainstorm cache:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCachedIdeas = useCallback((cacheKey: string, ideas: any[], searchResults: any[]) => {
|
||||||
|
try {
|
||||||
|
const cacheData = {
|
||||||
|
ideas,
|
||||||
|
searchResults,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
sessionStorage.setItem(cacheKey, JSON.stringify(cacheData));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to cache brainstorm ideas:', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearCache = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(sessionStorage);
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (key.startsWith('brainstorm_ideas_')) {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to clear brainstorm cache:', e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = async (ev: any) => {
|
||||||
|
try {
|
||||||
|
// Store the event for refresh functionality
|
||||||
|
(window as any).lastBrainstormEvent = ev;
|
||||||
|
|
||||||
|
const { prompt, seed: ideaSeed, forceRefresh = false } = ev.detail || {};
|
||||||
|
const finalSeed = ideaSeed || prompt;
|
||||||
|
|
||||||
|
setBrainstormVisible(true);
|
||||||
|
setBrainstormStage('loading');
|
||||||
|
setLoaderMessageIndex(0);
|
||||||
|
|
||||||
|
// Special case: show most recent cached ideas when seed is 'cached'
|
||||||
|
if (finalSeed === 'cached') {
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(sessionStorage);
|
||||||
|
let mostRecentCache: BrainstormCacheData | null = null;
|
||||||
|
let mostRecentKey = '';
|
||||||
|
let mostRecentTimestamp = 0;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key.startsWith('brainstorm_ideas_')) {
|
||||||
|
const cached = sessionStorage.getItem(key);
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached);
|
||||||
|
if (isBrainstormCacheData(data) && data.timestamp > mostRecentTimestamp && data.ideas.length > 0) {
|
||||||
|
mostRecentTimestamp = data.timestamp;
|
||||||
|
mostRecentCache = data;
|
||||||
|
mostRecentKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mostRecentCache !== null) {
|
||||||
|
console.log('Showing most recent cached brainstorm ideas from:', mostRecentKey);
|
||||||
|
setIdeas(mostRecentCache.ideas);
|
||||||
|
setAiSearchPrompts(mostRecentCache.ideas.map((x) => x.prompt));
|
||||||
|
setSelectedPrompt(mostRecentCache.ideas[0]?.prompt || '');
|
||||||
|
setSearchResults(mostRecentCache.searchResults || []);
|
||||||
|
setIsUsingCache(true);
|
||||||
|
setBrainstormStage('select');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// No cached ideas found, close modal
|
||||||
|
setBrainstormVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load cached ideas:', e);
|
||||||
|
setBrainstormVisible(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first (unless force refresh)
|
||||||
|
const personaId = corePersona?.id?.toString();
|
||||||
|
const platformPersonaId = platformPersona?.id?.toString();
|
||||||
|
const cacheKey = getCacheKey(finalSeed, personaId, platformPersonaId);
|
||||||
|
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const cached = getCachedIdeas(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
console.log('Using cached brainstorm ideas for:', finalSeed);
|
||||||
|
setIdeas(cached.ideas);
|
||||||
|
setAiSearchPrompts(cached.ideas.map((x) => x.prompt));
|
||||||
|
setSelectedPrompt(cached.ideas[0]?.prompt || '');
|
||||||
|
setSearchResults(cached.searchResults || []);
|
||||||
|
setIsUsingCache(true);
|
||||||
|
setBrainstormStage('select');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUsingCache(false);
|
||||||
|
|
||||||
|
// Gentle loader progression
|
||||||
|
let step = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
step += 1;
|
||||||
|
setLoaderMessageIndex((idx: number) => Math.min(idx + 1, loaderMessages.length - 1));
|
||||||
|
if (step >= loaderMessages.length - 1) clearInterval(interval);
|
||||||
|
}, 700);
|
||||||
|
|
||||||
|
// First: run grounded search for the seed prompt
|
||||||
|
let results: any[] = [];
|
||||||
|
try {
|
||||||
|
const sr = await fetch('/api/brainstorm/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt: finalSeed })
|
||||||
|
});
|
||||||
|
if (sr.ok) {
|
||||||
|
const data = await sr.json();
|
||||||
|
results = data?.results || [];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setSearchResults(results);
|
||||||
|
|
||||||
|
// Then: request persona-aware brainstorm ideas using the search results
|
||||||
|
try {
|
||||||
|
const ir = await fetch('/api/brainstorm/ideas', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
seed: finalSeed,
|
||||||
|
persona: corePersona || null,
|
||||||
|
platformPersona: platformPersona || null,
|
||||||
|
results,
|
||||||
|
count: 5
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (ir.ok) {
|
||||||
|
const data = await ir.json();
|
||||||
|
const list = Array.isArray(data?.ideas) ? data.ideas : [];
|
||||||
|
setIdeas(list);
|
||||||
|
setAiSearchPrompts(list.map((x: any) => x.prompt));
|
||||||
|
setSelectedPrompt(list[0]?.prompt || '');
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
setCachedIdeas(cacheKey, list, results);
|
||||||
|
console.log('Cached brainstorm ideas for:', finalSeed);
|
||||||
|
} else {
|
||||||
|
setIdeas([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIdeas([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setBrainstormStage('select');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Brainstorm flow error:', e);
|
||||||
|
setBrainstormVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
||||||
|
return () => window.removeEventListener('linkedinwriter:runGoogleSearchForIdeas' as any, handler);
|
||||||
|
}, [corePersona, platformPersona, loaderMessages, getCacheKey, getCachedIdeas, setCachedIdeas, setBrainstormVisible, setBrainstormStage, setLoaderMessageIndex, setIdeas, setAiSearchPrompts, setSelectedPrompt, setSearchResults, setIsUsingCache]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Brainstorm Flow UI */}
|
||||||
|
{brainstormVisible && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10010, padding: 20 }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
width: 800,
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: '90vh',
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.25)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
}}>
|
||||||
|
{/* Fixed Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: 16,
|
||||||
|
background: '#0a66c2',
|
||||||
|
color: 'white',
|
||||||
|
fontWeight: 800,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
<div>Brainstorm: Google Search Prompts</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Force refresh by clearing cache and re-running
|
||||||
|
const { prompt, seed: ideaSeed } = (window as any).lastBrainstormEvent?.detail || {};
|
||||||
|
if (prompt || ideaSeed) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
||||||
|
detail: { prompt, seed: ideaSeed, forceRefresh: true }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
border: 'none',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
title="Refresh ideas (bypass cache)"
|
||||||
|
>
|
||||||
|
🔄
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
clearCache();
|
||||||
|
console.log('Brainstorm cache cleared');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.2)',
|
||||||
|
border: 'none',
|
||||||
|
color: 'white',
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: '4px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
title="Clear all cached brainstorm ideas"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setBrainstormVisible(false);
|
||||||
|
setBrainstormStage('loading');
|
||||||
|
setLoaderMessageIndex(0);
|
||||||
|
setAiSearchPrompts([]);
|
||||||
|
setSelectedPrompt('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setIdeas([]);
|
||||||
|
}}
|
||||||
|
style={{ background: 'rgba(255,255,255,0.2)', border: 'none', color: 'white', borderRadius: 8, padding: '6px 10px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
|
{brainstormStage === 'loading' && (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 12, alignItems: 'center' }}>
|
||||||
|
<div style={{ width: 16, height: 16, borderRadius: '50%', border: '2px solid #0a66c2', borderTopColor: 'transparent', animation: 'spin 0.8s linear infinite' }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 800, color: '#111827' }}>Preparing Google search prompts</div>
|
||||||
|
<div style={{ marginTop: 6, color: '#374151', fontSize: 14 }}>{loaderMessages[loaderMessageIndex]}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul style={{ margin: '12px 0 0 28px', color: '#6b7280', fontSize: 12, lineHeight: 1.6 }}>
|
||||||
|
<li>1/4 Persona-aware analysis</li>
|
||||||
|
<li>2/4 Seed expansion and entities</li>
|
||||||
|
<li>3/4 Grounding and timeliness checks</li>
|
||||||
|
<li>4/4 Output assembly</li>
|
||||||
|
</ul>
|
||||||
|
<style>{'@keyframes spin{to{transform:rotate(360deg)}}'}</style>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brainstormStage === 'select' && (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div style={{ marginBottom: 16, fontWeight: 700, color: '#1f2937', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
Select one prompt to run with Google Search
|
||||||
|
{isUsingCache && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#059669',
|
||||||
|
background: '#d1fae5',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontWeight: 500
|
||||||
|
}}>
|
||||||
|
📦 Cached
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gap: 12, marginBottom: 20 }}>
|
||||||
|
{aiSearchPrompts.map((p, i) => {
|
||||||
|
const rationale = ideas[i]?.rationale;
|
||||||
|
return (
|
||||||
|
<label key={i} style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'auto 1fr',
|
||||||
|
gap: 12,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '12px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 0.2s'
|
||||||
|
}}>
|
||||||
|
<input type="radio" name="aiPrompt" checked={selectedPrompt === p} onChange={() => setSelectedPrompt(p)} style={{ marginTop: 3 }} />
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, color: '#111827', fontWeight: 600, lineHeight: 1.4 }}>{p}</div>
|
||||||
|
{rationale && <div style={{ marginTop: 6, color: '#6b7280', fontSize: 12, lineHeight: 1.3 }}>{rationale}</div>}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brainstormStage === 'results' && (
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<div style={{ marginBottom: 16, fontWeight: 700, color: '#1f2937' }}>Search Results</div>
|
||||||
|
{searchResults.length === 0 ? (
|
||||||
|
<div style={{ color: '#6b7280' }}>No results or search unavailable. Try another prompt.</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gap: 12, marginBottom: 20 }}>
|
||||||
|
{searchResults.map((r: any, idx: number) => (
|
||||||
|
<div key={idx} style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '12px 16px' }}>
|
||||||
|
<div style={{ fontWeight: 700, color: '#111827', marginBottom: 4 }}>{r.title || r.name || 'Result'}</div>
|
||||||
|
<div style={{ color: '#374151', fontSize: 13, lineHeight: 1.4 }}>{r.snippet || r.description || r.content || ''}</div>
|
||||||
|
{r.url && (<div style={{ marginTop: 6, fontSize: 12, color: '#2563eb' }}><a href={r.url} target="_blank" rel="noreferrer">{r.url}</a></div>)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed Footer */}
|
||||||
|
{brainstormStage !== 'loading' && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderTop: '1px solid #e5e7eb',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
flexShrink: 0,
|
||||||
|
backgroundColor: '#f9fafb'
|
||||||
|
}}>
|
||||||
|
{brainstormStage === 'select' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Send prompt to copilot chat input to generate a post from this prompt
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:copilotSeedFromPrompt', { detail: { prompt: selectedPrompt } }));
|
||||||
|
setBrainstormVisible(false);
|
||||||
|
}}
|
||||||
|
disabled={!selectedPrompt}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: selectedPrompt ? '#111827' : '#9ca3af',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: selectedPrompt ? 'pointer' : 'not-allowed',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate post from this prompt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBrainstormVisible(false)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
// Use existing Google grounding flow via backend LinkedInService
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/brainstorm/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt: selectedPrompt })
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
setSearchResults(data?.results || []);
|
||||||
|
setBrainstormStage('results');
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
setBrainstormStage('results');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setSearchResults([]);
|
||||||
|
setBrainstormStage('results');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!selectedPrompt}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: selectedPrompt ? '#0a66c2' : '#c7d2fe',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: selectedPrompt ? 'pointer' : 'not-allowed',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Run Google Search
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{brainstormStage === 'results' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setBrainstormStage('select')}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Prompts
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Seed Copilot chat to generate a post
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:copilotSeedFromPrompt', { detail: { prompt: selectedPrompt } }));
|
||||||
|
setBrainstormVisible(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#111827',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Generate post from this prompt
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setBrainstormVisible(false)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#0a66c2',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BrainstormFlow;
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useCopilotAction, useCopilotContext } from '@copilotkit/react-core';
|
||||||
|
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
|
||||||
|
const useCopilotActionTyped = useCopilotAction as any;
|
||||||
|
|
||||||
|
// Optional debug flag: set to true to enable verbose logs locally
|
||||||
|
const DEBUG_LINKEDIN = false;
|
||||||
|
|
||||||
|
interface CopilotActionsProps {
|
||||||
|
draft: string;
|
||||||
|
context: string;
|
||||||
|
userPreferences: any;
|
||||||
|
justGeneratedContent: boolean;
|
||||||
|
handleContextChange: (value: string) => void;
|
||||||
|
setDraft: (draft: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: This is implemented as a hook-like utility, not a rendered component.
|
||||||
|
// It returns the getIntelligentSuggestions function for use by the caller.
|
||||||
|
const CopilotActions = ({
|
||||||
|
draft,
|
||||||
|
context,
|
||||||
|
userPreferences,
|
||||||
|
justGeneratedContent,
|
||||||
|
handleContextChange,
|
||||||
|
setDraft
|
||||||
|
}: CopilotActionsProps) => {
|
||||||
|
const { corePersona, platformPersona } = usePlatformPersonaContext();
|
||||||
|
const copilotContext = useCopilotContext();
|
||||||
|
|
||||||
|
// Listen for copilot seed events to open sidebar with prompt
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = (ev: any) => {
|
||||||
|
try {
|
||||||
|
const { prompt } = ev.detail || {};
|
||||||
|
if (!prompt) return;
|
||||||
|
|
||||||
|
// First, open the copilot sidebar
|
||||||
|
const copilotButton = document.querySelector('.copilotkit-open-button') ||
|
||||||
|
document.querySelector('[data-copilot-open]') ||
|
||||||
|
document.querySelector('button[aria-label*="Open"]') ||
|
||||||
|
document.querySelector('.alwrity-copilot-sidebar button') ||
|
||||||
|
document.querySelector('[data-testid="copilot-open-button"]');
|
||||||
|
|
||||||
|
if (copilotButton) {
|
||||||
|
(copilotButton as HTMLElement).click();
|
||||||
|
|
||||||
|
// Try context-based approach first (if available)
|
||||||
|
if (copilotContext && typeof copilotContext === 'object') {
|
||||||
|
try {
|
||||||
|
// Check if context has any message sending capabilities
|
||||||
|
if ('sendMessage' in copilotContext && typeof copilotContext.sendMessage === 'function') {
|
||||||
|
setTimeout(() => {
|
||||||
|
(copilotContext as any).sendMessage(prompt);
|
||||||
|
console.log('Message sent via context');
|
||||||
|
return;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Context-based approach failed, falling back to DOM manipulation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative: Try to trigger the generateFromPrompt action directly
|
||||||
|
setTimeout(() => {
|
||||||
|
// Try to find and trigger the generateFromPrompt action
|
||||||
|
const actionButton = document.querySelector('[data-action="generateFromPrompt"]') ||
|
||||||
|
document.querySelector('button[title*="generateFromPrompt"]');
|
||||||
|
if (actionButton) {
|
||||||
|
// Set the prompt in a temporary storage for the action to pick up
|
||||||
|
(window as any).tempPromptForGeneration = prompt;
|
||||||
|
(actionButton as HTMLElement).click();
|
||||||
|
console.log('Triggered generateFromPrompt action with:', prompt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
// Fallback: Wait a bit for the sidebar to open, then set the input value
|
||||||
|
setTimeout(() => {
|
||||||
|
// Try multiple selectors for the chat input
|
||||||
|
const chatInput = document.querySelector('.copilotkit-chat-input') ||
|
||||||
|
document.querySelector('textarea[placeholder*="message"]') ||
|
||||||
|
document.querySelector('input[placeholder*="message"]') ||
|
||||||
|
document.querySelector('.copilot-chat-input') ||
|
||||||
|
document.querySelector('[data-testid="chat-input"]') ||
|
||||||
|
document.querySelector('textarea[data-testid="chat-input"]') ||
|
||||||
|
document.querySelector('.copilotkit-chat-input textarea') ||
|
||||||
|
document.querySelector('.copilotkit-chat-input input') ||
|
||||||
|
document.querySelector('textarea[data-copilot-input]') ||
|
||||||
|
document.querySelector('input[data-copilot-input]');
|
||||||
|
|
||||||
|
if (chatInput) {
|
||||||
|
const inputElement = chatInput as HTMLInputElement | HTMLTextAreaElement;
|
||||||
|
|
||||||
|
// Check if input is disabled or read-only
|
||||||
|
if (inputElement.disabled || inputElement.readOnly) {
|
||||||
|
console.warn('Input is disabled or read-only, attempting to enable it');
|
||||||
|
inputElement.disabled = false;
|
||||||
|
inputElement.readOnly = false;
|
||||||
|
inputElement.removeAttribute('disabled');
|
||||||
|
inputElement.removeAttribute('readonly');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear any existing value first
|
||||||
|
inputElement.value = '';
|
||||||
|
|
||||||
|
// Set the new value
|
||||||
|
inputElement.value = prompt;
|
||||||
|
|
||||||
|
// Focus the input
|
||||||
|
inputElement.focus();
|
||||||
|
|
||||||
|
// Trigger multiple events to ensure React state updates
|
||||||
|
const inputEvent = new Event('input', { bubbles: true, cancelable: true });
|
||||||
|
const changeEvent = new Event('change', { bubbles: true, cancelable: true });
|
||||||
|
const keyupEvent = new Event('keyup', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
// Set the target property for React synthetic events
|
||||||
|
Object.defineProperty(inputEvent, 'target', { value: inputElement, enumerable: true });
|
||||||
|
Object.defineProperty(changeEvent, 'target', { value: inputElement, enumerable: true });
|
||||||
|
Object.defineProperty(keyupEvent, 'target', { value: inputElement, enumerable: true });
|
||||||
|
|
||||||
|
// Dispatch events in sequence
|
||||||
|
inputElement.dispatchEvent(inputEvent);
|
||||||
|
inputElement.dispatchEvent(changeEvent);
|
||||||
|
inputElement.dispatchEvent(keyupEvent);
|
||||||
|
|
||||||
|
// Try to trigger a React synthetic event with more properties
|
||||||
|
const syntheticEvent = new Event('input', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(syntheticEvent, 'target', { value: inputElement, enumerable: true });
|
||||||
|
Object.defineProperty(syntheticEvent, 'currentTarget', { value: inputElement, enumerable: true });
|
||||||
|
Object.defineProperty(syntheticEvent, 'nativeEvent', { value: syntheticEvent, enumerable: true });
|
||||||
|
inputElement.dispatchEvent(syntheticEvent);
|
||||||
|
|
||||||
|
// Also try to trigger a focus event to ensure the input is active
|
||||||
|
const focusEvent = new Event('focus', { bubbles: true, cancelable: true });
|
||||||
|
inputElement.dispatchEvent(focusEvent);
|
||||||
|
|
||||||
|
// Try to find and enable the send button if it exists
|
||||||
|
setTimeout(() => {
|
||||||
|
const sendButton = document.querySelector('button[type="submit"]') ||
|
||||||
|
document.querySelector('button[data-copilot-send]') ||
|
||||||
|
document.querySelector('.copilotkit-send-button') ||
|
||||||
|
document.querySelector('button[aria-label*="Send"]') ||
|
||||||
|
document.querySelector('button[title*="Send"]');
|
||||||
|
|
||||||
|
if (sendButton) {
|
||||||
|
// Remove disabled attribute if it exists
|
||||||
|
(sendButton as HTMLButtonElement).disabled = false;
|
||||||
|
(sendButton as HTMLButtonElement).removeAttribute('disabled');
|
||||||
|
console.log('Send button enabled');
|
||||||
|
|
||||||
|
// Try to automatically send the message after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!(sendButton as HTMLButtonElement).disabled) {
|
||||||
|
(sendButton as HTMLButtonElement).click();
|
||||||
|
console.log('Message sent automatically');
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Alternative: Try to simulate Enter key press
|
||||||
|
setTimeout(() => {
|
||||||
|
const enterEvent = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
keyCode: 13,
|
||||||
|
which: 13,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
});
|
||||||
|
inputElement.dispatchEvent(enterEvent);
|
||||||
|
|
||||||
|
const enterUpEvent = new KeyboardEvent('keyup', {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
keyCode: 13,
|
||||||
|
which: 13,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
});
|
||||||
|
inputElement.dispatchEvent(enterUpEvent);
|
||||||
|
console.log('Enter key simulated');
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
console.log('Copilot sidebar opened with prompt:', prompt);
|
||||||
|
console.log('Input element details:', {
|
||||||
|
value: inputElement.value,
|
||||||
|
disabled: inputElement.disabled,
|
||||||
|
readOnly: inputElement.readOnly,
|
||||||
|
className: inputElement.className,
|
||||||
|
id: inputElement.id
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('Could not find copilot chat input to prefill. Available elements:',
|
||||||
|
Array.from(document.querySelectorAll('textarea, input')).map(el => ({
|
||||||
|
tag: el.tagName,
|
||||||
|
className: el.className,
|
||||||
|
placeholder: el.getAttribute('placeholder'),
|
||||||
|
id: el.id,
|
||||||
|
'data-copilot-input': el.getAttribute('data-copilot-input')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1000); // Increased timeout to ensure sidebar is fully loaded
|
||||||
|
} else {
|
||||||
|
console.warn('Could not find copilot sidebar button to open. Available buttons:',
|
||||||
|
Array.from(document.querySelectorAll('button')).map(btn => ({
|
||||||
|
className: btn.className,
|
||||||
|
text: btn.textContent?.trim(),
|
||||||
|
'aria-label': btn.getAttribute('aria-label')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error handling copilot seed event:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
||||||
|
return () => window.removeEventListener('linkedinwriter:copilotSeedFromPrompt' as any, handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Allow external prompts to trigger content generation
|
||||||
|
useCopilotActionTyped({
|
||||||
|
name: 'generateFromPrompt',
|
||||||
|
description: 'Generate LinkedIn content from a specific prompt or idea',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'prompt', type: 'string', description: 'The prompt or idea to generate content from', required: true }
|
||||||
|
],
|
||||||
|
handler: async ({ prompt }: { prompt: string }) => {
|
||||||
|
// Check for temporary prompt from brainstorm flow
|
||||||
|
const finalPrompt = prompt || (window as any).tempPromptForGeneration;
|
||||||
|
|
||||||
|
if (!finalPrompt) {
|
||||||
|
return { success: false, message: 'No prompt provided' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the temporary prompt
|
||||||
|
if ((window as any).tempPromptForGeneration) {
|
||||||
|
delete (window as any).tempPromptForGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the prompt as context and trigger generation
|
||||||
|
handleContextChange(finalPrompt);
|
||||||
|
|
||||||
|
// Use the existing LinkedIn post generation action
|
||||||
|
try {
|
||||||
|
// This will trigger the existing generateLinkedInPost action
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Generating LinkedIn content from prompt: "${finalPrompt}"`,
|
||||||
|
prompt: finalPrompt
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, message: 'Failed to generate content from prompt' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow Copilot to edit the draft with specific operations
|
||||||
|
useCopilotActionTyped({
|
||||||
|
name: 'editLinkedInDraft',
|
||||||
|
description: 'Apply a quick style or structural edit to the current LinkedIn draft',
|
||||||
|
parameters: [
|
||||||
|
{ name: 'operation', type: 'string', description: 'The edit operation to perform', required: true, enum: ['Casual', 'Professional', 'TightenHook', 'AddCTA', 'Shorten', 'Lengthen'] }
|
||||||
|
],
|
||||||
|
handler: async ({ operation }: { operation: string }) => {
|
||||||
|
const currentDraft = draft || '';
|
||||||
|
if (!currentDraft) {
|
||||||
|
return { success: false, message: 'No draft content to edit' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let editedContent = currentDraft;
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case 'Casual':
|
||||||
|
editedContent = currentDraft.replace(/\b(utilize|implement|facilitate|leverage)\b/gi, (match) => {
|
||||||
|
const casual = { utilize: 'use', implement: 'put in place', facilitate: 'help', leverage: 'use' };
|
||||||
|
return casual[match.toLowerCase() as keyof typeof casual] || match;
|
||||||
|
});
|
||||||
|
editedContent = editedContent.replace(/\./g, '! 😊');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Professional':
|
||||||
|
editedContent = currentDraft.replace(/\b(use|put in place|help)\b/gi, (match) => {
|
||||||
|
const professional = { use: 'utilize', 'put in place': 'implement', help: 'facilitate' };
|
||||||
|
return professional[match.toLowerCase() as keyof typeof professional] || match;
|
||||||
|
});
|
||||||
|
editedContent = editedContent.replace(/! 😊/g, '.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'TightenHook':
|
||||||
|
const lines = currentDraft.split('\n');
|
||||||
|
if (lines.length > 0) {
|
||||||
|
const firstLine = lines[0];
|
||||||
|
const tightened = firstLine.length > 100 ? firstLine.substring(0, 100) + '...' : firstLine;
|
||||||
|
lines[0] = tightened;
|
||||||
|
editedContent = lines.join('\n');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'AddCTA':
|
||||||
|
if (!/\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(currentDraft)) {
|
||||||
|
editedContent = currentDraft + '\n\nWhat are your thoughts on this? Share your experience in the comments below!';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Shorten':
|
||||||
|
if (currentDraft.length > 200) {
|
||||||
|
editedContent = currentDraft.substring(0, 200) + '...';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Lengthen':
|
||||||
|
if (currentDraft.length < 500) {
|
||||||
|
editedContent = currentDraft + '\n\nThis approach has shown remarkable results in our industry. The key is to maintain consistency while adapting to changing market conditions.';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { success: false, message: 'Unknown operation' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the edit action to show the diff preview
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:applyEdit', {
|
||||||
|
detail: { target: editedContent }
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { success: true, message: `Draft ${operation.toLowerCase()} applied`, content: editedContent };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intelligent, stage-aware suggestions (memoized to prevent infinite re-rendering)
|
||||||
|
const getIntelligentSuggestions = useMemo(() => {
|
||||||
|
const hasContent = draft && draft.trim().length > 0;
|
||||||
|
const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|connect|message|dm|reach out)\b/i.test(draft || '');
|
||||||
|
const hasHashtags = /#[A-Za-z0-9_]+/.test(draft || '');
|
||||||
|
const isLong = (draft || '').length > 500;
|
||||||
|
|
||||||
|
// Debug logging for suggestions
|
||||||
|
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Generating suggestions:', {
|
||||||
|
hasContent,
|
||||||
|
justGeneratedContent,
|
||||||
|
draftLength: draft?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasContent) {
|
||||||
|
// Initial suggestions for content creation
|
||||||
|
const initialSuggestions = [
|
||||||
|
{ title: '📝 LinkedIn Post', message: 'Use tool generateLinkedInPost to create a professional LinkedIn post for your industry.' },
|
||||||
|
{ title: '📄 Article', message: 'Use tool generateLinkedInArticle to write a thought leadership article.' },
|
||||||
|
{ title: '🎠 Carousel', message: 'Use tool generateLinkedInCarousel to create a multi-slide carousel presentation.' },
|
||||||
|
{ title: '🎬 Video Script', message: 'Use tool generateLinkedInVideoScript to draft a video script for LinkedIn.' },
|
||||||
|
{ title: '💬 Comment Response', message: 'Use tool generateLinkedInCommentResponse to craft a professional comment reply.' },
|
||||||
|
{ title: '🖼️ Generate Post Image', message: 'Use tool generateLinkedInImagePrompts to create professional images for your LinkedIn content.' },
|
||||||
|
{ title: '🎨 Visual Content', message: 'Create engaging visual content with AI-generated images optimized for LinkedIn.' }
|
||||||
|
];
|
||||||
|
console.log('[LinkedIn Writer] Initial suggestions:', initialSuggestions);
|
||||||
|
return initialSuggestions;
|
||||||
|
} else {
|
||||||
|
// Refinement suggestions for existing content - use direct edit actions
|
||||||
|
const refinementSuggestions = [
|
||||||
|
{ title: '🙂 Make it casual', message: 'Use tool editLinkedInDraft with operation Casual' },
|
||||||
|
{ title: '💼 Make it professional', message: 'Use tool editLinkedInDraft with operation Professional' },
|
||||||
|
{ title: '✨ Tighten hook', message: 'Use tool editLinkedInDraft with operation TightenHook' },
|
||||||
|
{ title: '📣 Add a CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' },
|
||||||
|
{ title: '✂️ Shorten', message: 'Use tool editLinkedInDraft with operation Shorten' },
|
||||||
|
{ title: '➕ Lengthen', message: 'Use tool editLinkedInDraft with operation Lengthen' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add special suggestions when content was just generated
|
||||||
|
if (justGeneratedContent) {
|
||||||
|
console.log('[LinkedIn Writer] Adding post-generation suggestions');
|
||||||
|
refinementSuggestions.unshift(
|
||||||
|
{
|
||||||
|
title: '🎉 Content Generated! Next Steps:',
|
||||||
|
message: 'Great! Your content is ready. Now let\'s enhance it with images and make it perfect for LinkedIn.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '🖼️ Generate Post Image',
|
||||||
|
message: 'Use tool generateLinkedInImagePrompts to create professional images for this LinkedIn post'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add contextual suggestions based on content analysis
|
||||||
|
if (!hasCTA) {
|
||||||
|
refinementSuggestions.push({ title: '📣 Add CTA', message: 'Use tool editLinkedInDraft with operation AddCTA' });
|
||||||
|
}
|
||||||
|
if (!hasHashtags) {
|
||||||
|
refinementSuggestions.push({ title: '🏷️ Add hashtags', message: 'Use tool addLinkedInHashtags' });
|
||||||
|
}
|
||||||
|
if (isLong) {
|
||||||
|
refinementSuggestions.push({ title: '📝 Summarize intro', message: 'Use tool editLinkedInDraft with operation Shorten' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add image generation suggestion when there's content
|
||||||
|
if (draft && draft.trim().length > 0) {
|
||||||
|
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Adding image generation suggestion');
|
||||||
|
// Make image generation suggestion more prominent
|
||||||
|
refinementSuggestions.push({
|
||||||
|
title: '🖼️ Generate Post Image',
|
||||||
|
message: 'Use tool generateLinkedInImagePrompts to create professional images for this LinkedIn post'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add contextual image suggestions based on content type
|
||||||
|
if (draft.includes('digital transformation') || draft.includes('technology') || draft.includes('innovation')) {
|
||||||
|
refinementSuggestions.push({
|
||||||
|
title: '🚀 Tech-Focused Image',
|
||||||
|
message: 'Use tool generateLinkedInImagePrompts to create technology-themed professional images for this post'
|
||||||
|
});
|
||||||
|
} else if (draft.includes('business') || draft.includes('strategy') || draft.includes('growth')) {
|
||||||
|
refinementSuggestions.push({
|
||||||
|
title: '💼 Business Image',
|
||||||
|
message: 'Use tool generateLinkedInImagePrompts to create business-focused professional images for this post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG_LINKEDIN) console.log('[LinkedIn Writer] Final suggestions:', refinementSuggestions);
|
||||||
|
return refinementSuggestions;
|
||||||
|
}
|
||||||
|
}, [draft, justGeneratedContent]);
|
||||||
|
|
||||||
|
// Return the suggestions function directly
|
||||||
|
return getIntelligentSuggestions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopilotActions;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { LinkedInPreferences } from '../utils/storageUtils';
|
import { LinkedInPreferences } from '../utils/storageUtils';
|
||||||
|
import { PersonaChip } from '../../TextEditor/ContentPreviewHeaderComponents';
|
||||||
|
import { usePlatformPersonaContext } from '../../shared/PersonaContext/PlatformPersonaProvider';
|
||||||
|
import BrainstormFlow from './BrainstormFlow';
|
||||||
// Temporary fix: use require for image import
|
// Temporary fix: use require for image import
|
||||||
const alwrityLogo = require('../../../assets/images/alwrity_logo.png');
|
const alwrityLogo = require('../../../assets/images/alwrity_logo.png');
|
||||||
|
|
||||||
@@ -22,10 +25,59 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
onClearHistory,
|
onClearHistory,
|
||||||
getHistoryLength
|
getHistoryLength
|
||||||
}) => {
|
}) => {
|
||||||
|
const [personaOverride, setPersonaOverride] = useState<any>(null);
|
||||||
|
const { corePersona, platformPersona } = usePlatformPersonaContext();
|
||||||
|
|
||||||
|
// Brainstorm modal state
|
||||||
|
const [showBrainstormModal, setShowBrainstormModal] = useState(false);
|
||||||
|
const [seed, setSeed] = useState('');
|
||||||
|
const [usePersona, setUsePersona] = useState(true);
|
||||||
|
const [useGoogleSearch, setUseGoogleSearch] = useState(true);
|
||||||
|
const [includeTrending, setIncludeTrending] = useState(false);
|
||||||
|
const [remarketContent, setRemarketContent] = useState(false);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [aiSearchPrompt, setAiSearchPrompt] = useState('');
|
||||||
|
|
||||||
|
// BrainstormFlow state management
|
||||||
|
const [brainstormVisible, setBrainstormVisible] = useState(false);
|
||||||
|
const [brainstormStage, setBrainstormStage] = useState<'loading' | 'select' | 'results'>('loading');
|
||||||
|
const [loaderMessageIndex, setLoaderMessageIndex] = useState(0);
|
||||||
|
const [aiSearchPrompts, setAiSearchPrompts] = useState<string[]>([]);
|
||||||
|
const [selectedPrompt, setSelectedPrompt] = useState<string>('');
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [ideas, setIdeas] = useState<{ prompt: string; rationale?: string }[]>([]);
|
||||||
|
const [isUsingCache, setIsUsingCache] = useState(false);
|
||||||
|
|
||||||
|
// Check if there are cached brainstorm ideas
|
||||||
|
const hasCachedIdeas = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(sessionStorage);
|
||||||
|
return keys.some(key => {
|
||||||
|
if (key.startsWith('brainstorm_ideas_')) {
|
||||||
|
const cached = sessionStorage.getItem(key);
|
||||||
|
if (cached) {
|
||||||
|
const data = JSON.parse(cached);
|
||||||
|
// Check if cache is less than 1 hour old and has ideas
|
||||||
|
return Date.now() - data.timestamp < 3600000 && data.ideas && data.ideas.length > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [showBrainstormModal]); // Re-check when modal opens
|
||||||
|
|
||||||
const handlePreferenceChange = (key: keyof LinkedInPreferences, value: any) => {
|
const handlePreferenceChange = (key: keyof LinkedInPreferences, value: any) => {
|
||||||
onPreferencesChange({ [key]: value });
|
onPreferencesChange({ [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePersonaUpdate = (personaData: any) => {
|
||||||
|
console.log('Persona updated in LinkedIn writer:', personaData);
|
||||||
|
setPersonaOverride(personaData);
|
||||||
|
// You can also save this to user preferences or pass it up to the parent component
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'linear-gradient(135deg, #0a66c2 0%, #0056b3 100%)',
|
background: 'linear-gradient(135deg, #0a66c2 0%, #0056b3 100%)',
|
||||||
@@ -68,7 +120,6 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => onPreferencesModalChange(true)}
|
onMouseEnter={() => onPreferencesModalChange(true)}
|
||||||
onMouseLeave={() => onPreferencesModalChange(false)}
|
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -88,20 +139,24 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
|
|
||||||
{/* Preferences Modal */}
|
{/* Preferences Modal */}
|
||||||
{showPreferencesModal && (
|
{showPreferencesModal && (
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute',
|
style={{
|
||||||
top: '100%',
|
position: 'absolute',
|
||||||
left: '0',
|
top: '100%',
|
||||||
width: '400px',
|
left: '0',
|
||||||
background: 'white',
|
width: '400px',
|
||||||
borderRadius: '12px',
|
background: 'white',
|
||||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.15)',
|
borderRadius: '12px',
|
||||||
border: '1px solid #e9ecef',
|
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.15)',
|
||||||
padding: '20px',
|
border: '1px solid #e9ecef',
|
||||||
zIndex: 1000,
|
padding: '20px',
|
||||||
marginTop: '8px',
|
zIndex: 1000,
|
||||||
animation: 'slideIn 0.2s ease-out'
|
marginTop: '8px',
|
||||||
}}>
|
animation: 'slideIn 0.2s ease-out'
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onPreferencesModalChange(true)}
|
||||||
|
onMouseLeave={() => onPreferencesModalChange(false)}
|
||||||
|
>
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<h4 style={{ margin: '0 0 12px 0', color: '#333', fontSize: '16px', fontWeight: 600 }}>
|
<h4 style={{ margin: '0 0 12px 0', color: '#333', fontSize: '16px', fontWeight: 600 }}>
|
||||||
Content Preferences & Persona
|
Content Preferences & Persona
|
||||||
@@ -144,6 +199,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Persona Chip */}
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -153,18 +209,11 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: '1px solid #e2e8f0'
|
border: '1px solid #e2e8f0'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
<PersonaChip
|
||||||
<span style={{ fontSize: '16px' }}>🎭</span>
|
platform="linkedin"
|
||||||
<span style={{ fontSize: '16px' }}>🎯</span>
|
userId={1}
|
||||||
</div>
|
onPersonaUpdate={handlePersonaUpdate}
|
||||||
<div style={{ flex: 1 }}>
|
/>
|
||||||
<div style={{ fontSize: '13px', fontWeight: '600', color: '#2d3748', marginBottom: '2px' }}>
|
|
||||||
The Digital Strategist (The Insightful Guide)
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
|
||||||
88% accuracy | Platform: LinkedIn Optimized
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -173,7 +222,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
color: '#666',
|
color: '#666',
|
||||||
fontStyle: 'italic'
|
fontStyle: 'italic'
|
||||||
}}>
|
}}>
|
||||||
Hover over persona for detailed information
|
Click persona to edit writing style, tone, and preferences
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -347,7 +396,50 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
{/* Today's Tasks Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('linkedinwriter:showTodaysTasks'))}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6
|
||||||
|
}}
|
||||||
|
title="View today's tasks"
|
||||||
|
>
|
||||||
|
📅 Today's Tasks
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Brainstorm Ideas Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBrainstormModal(true)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
color: 'white',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 500,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6
|
||||||
|
}}
|
||||||
|
title="Brainstorm content ideas"
|
||||||
|
>
|
||||||
|
💡 Brainstorm Ideas
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Clear Memory Button */}
|
||||||
<button
|
<button
|
||||||
onClick={onClearHistory}
|
onClick={onClearHistory}
|
||||||
style={{
|
style={{
|
||||||
@@ -366,6 +458,390 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Initial Brainstorm Modal */}
|
||||||
|
{showBrainstormModal && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 }}>
|
||||||
|
<div style={{ background: 'white', width: 720, maxWidth: '92vw', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)', overflow: 'hidden' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '18px 20px', background: 'linear-gradient(135deg, #0a66c2 0%, #125ea2 100%)', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 16 }}>Brainstorm LinkedIn Content Ideas</div>
|
||||||
|
<button onClick={() => setShowBrainstormModal(false)} style={{ background: 'rgba(255,255,255,0.2)', border: 'none', color: 'white', borderRadius: 8, padding: '6px 10px', cursor: 'pointer' }}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: '1.1fr 0.9fr', gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 10, fontWeight: 700, color: '#1f2937' }}>Options</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
title="Use your personalized writing persona to generate content that matches your unique voice, tone, and style preferences."
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#0a66c2';
|
||||||
|
e.currentTarget.style.backgroundColor = '#f8f9ff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={usePersona}
|
||||||
|
onChange={(e) => setUsePersona(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
accentColor: '#0a66c2',
|
||||||
|
transform: 'scale(1.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontWeight: 600, color: '#1f2937' }}>Use Persona</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
title="Enable Google Search to find current, relevant information and trending topics for your content ideas."
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#0a66c2';
|
||||||
|
e.currentTarget.style.backgroundColor = '#f8f9ff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useGoogleSearch}
|
||||||
|
onChange={(e) => setUseGoogleSearch(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
accentColor: '#0a66c2',
|
||||||
|
transform: 'scale(1.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontWeight: 600, color: '#1f2937' }}>Google Search</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
title="Include trending topics and current events to make your content more timely and engaging."
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#0a66c2';
|
||||||
|
e.currentTarget.style.backgroundColor = '#f8f9ff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeTrending}
|
||||||
|
onChange={(e) => setIncludeTrending(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
accentColor: '#0a66c2',
|
||||||
|
transform: 'scale(1.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontWeight: 600, color: '#1f2937' }}>Trending Topics</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
title="Repurpose and remarket your existing high-performing content into new formats and angles."
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#0a66c2';
|
||||||
|
e.currentTarget.style.backgroundColor = '#f8f9ff';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
e.currentTarget.style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={remarketContent}
|
||||||
|
onChange={(e) => setRemarketContent(e.target.checked)}
|
||||||
|
style={{
|
||||||
|
accentColor: '#0a66c2',
|
||||||
|
transform: 'scale(1.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ fontWeight: 600, color: '#1f2937' }}>Remarket Content</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||||||
|
<div style={{ fontWeight: 700, color: '#1f2937' }}>Idea Seed (optional)</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={seed}
|
||||||
|
onChange={(e) => setSeed(e.target.value)}
|
||||||
|
placeholder={corePersona?.core_belief ? `Ex: Show how "${corePersona.core_belief}" applies to SMB founders this quarter` : 'Add a theme, problem, or audience'}
|
||||||
|
rows={3}
|
||||||
|
style={{ width: '100%', border: '1px solid #e5e7eb', borderRadius: 10, padding: '10px 12px', fontSize: 14, resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#6b7280' }}>
|
||||||
|
Alwrity It requires Google Search enabled and an idea seed with at least 4 words.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const words = (seed || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (!useGoogleSearch || words.length < 4) return;
|
||||||
|
const personaLine = corePersona ? `${corePersona.persona_name} (${corePersona.archetype})` : 'the user\'s writing persona';
|
||||||
|
const tone = (corePersona as any)?.tonal_range?.default_tone || (platformPersona as any)?.tonal_range?.default_tone || 'professional';
|
||||||
|
const goTo = corePersona?.linguistic_fingerprint?.lexical_features?.go_to_words?.slice(0,5)?.join(', ');
|
||||||
|
const platformHints = platformPersona ? `Respect LinkedIn constraints like character limits and engagement patterns.` : '';
|
||||||
|
const trending = includeTrending ? 'Blend industry trending topics.' : '';
|
||||||
|
const repurpose = remarketContent ? 'Consider repurposing top-performing content into new angles.' : '';
|
||||||
|
const prompt = `You are an expert LinkedIn content strategist writing in a ${tone} tone for ${personaLine}. Generate a list of highly-relevant, specific topic ideas based on this seed: "${seed}". Prioritize originality, practical value, and thought leadership. ${platformHints} ${trending} ${repurpose} Use current (2024–2025) language and avoid generic suggestions.`.trim();
|
||||||
|
setAiSearchPrompt(prompt);
|
||||||
|
setShowConfirm(true);
|
||||||
|
}}
|
||||||
|
disabled={!(useGoogleSearch && (seed || '').trim().split(/\s+/).filter(Boolean).length >= 4)}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: 8, border: '1px solid #0a66c2', background: useGoogleSearch && (seed || '').trim().split(/\s+/).filter(Boolean).length >= 4 ? '#0a66c2' : '#c7d2fe', color: 'white', fontWeight: 800, cursor: useGoogleSearch && (seed || '').trim().split(/\s+/).filter(Boolean).length >= 4 ? 'pointer' : 'not-allowed' }}
|
||||||
|
>
|
||||||
|
Alwrity It
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, color: '#1f2937', marginBottom: 6 }}>Quick Actions</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (hasCachedIdeas) {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
||||||
|
detail: { prompt: 'View cached ideas', seed: 'cached', forceRefresh: false }
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', {
|
||||||
|
detail: { usePersona, useGoogleSearch, includeTrending, remarketContent, seed }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setShowBrainstormModal(false);
|
||||||
|
setBrainstormVisible(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: hasCachedIdeas ? '#0a66c2' : '#6b7280',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 14
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasCachedIdeas ? 'View Previous Ideas' : 'Generate Ideas'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions Section */}
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<div style={{ fontWeight: 700, color: '#1f2937', marginBottom: 8 }}>💡 Suggestions</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSeed('AI and automation trends in 2024')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#f8f9ff',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#e0e7ff';
|
||||||
|
e.currentTarget.style.borderColor = '#0a66c2';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f8f9ff';
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🤖 AI and automation trends
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSeed('Remote work productivity tips')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#f8f9ff',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#e0e7ff';
|
||||||
|
e.currentTarget.style.borderColor = '#0a66c2';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f8f9ff';
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🏠 Remote work productivity
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSeed('Leadership lessons from failures')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#f8f9ff',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#e0e7ff';
|
||||||
|
e.currentTarget.style.borderColor = '#0a66c2';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f8f9ff';
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎯 Leadership lessons
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSeed('Industry insights and predictions')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: '#f8f9ff',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#374151',
|
||||||
|
textAlign: 'left',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = '#e0e7ff';
|
||||||
|
e.currentTarget.style.borderColor = '#0a66c2';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = '#f8f9ff';
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📈 Industry insights
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: 16, borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f9fafb' }}>
|
||||||
|
<div style={{ color: '#6b7280', fontSize: 12 }}>
|
||||||
|
{hasCachedIdeas ? 'You have previously generated ideas. Click "View Previous Ideas" to see them.' : 'These settings guide idea generation. You can fine-tune results in the editor.'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10 }}>
|
||||||
|
<button onClick={() => setShowBrainstormModal(false)} style={{ padding: '10px 16px', borderRadius: 8, background: 'white', border: '1px solid #e5e7eb', cursor: 'pointer', fontWeight: 700 }}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Modal for AI Search Prompt */}
|
||||||
|
{showConfirm && (
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10001 }}>
|
||||||
|
<div style={{ background: 'white', width: 680, maxWidth: '92vw', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.25)', overflow: 'hidden' }}>
|
||||||
|
<div style={{ padding: '16px 18px', background: '#0a66c2', color: 'white', fontWeight: 800 }}>Confirm Google Search Prompt</div>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<div style={{ fontSize: 13, color: '#374151', marginBottom: 8 }}>We crafted this AI prompt using your persona and seed. Review and confirm to run Google Search for topic ideas.</div>
|
||||||
|
<textarea value={aiSearchPrompt} onChange={(e) => setAiSearchPrompt(e.target.value)} rows={6} style={{ width: '100%', border: '1px solid #e5e7eb', borderRadius: 10, padding: '10px 12px', fontSize: 13 }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 12, borderTop: '1px solid #e5e7eb', display: 'flex', justifyContent: 'flex-end', gap: 10, background: '#f9fafb' }}>
|
||||||
|
<button onClick={() => setShowConfirm(false)} style={{ padding: '8px 12px', borderRadius: 8, background: 'white', border: '1px solid #e5e7eb', cursor: 'pointer', fontWeight: 700 }}>Back</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
window.dispatchEvent(new CustomEvent('linkedinwriter:runGoogleSearchForIdeas', { detail: { prompt: aiSearchPrompt, seed, usePersona, includeTrending, remarketContent } }));
|
||||||
|
setShowConfirm(false);
|
||||||
|
setShowBrainstormModal(false);
|
||||||
|
setBrainstormVisible(true);
|
||||||
|
}}
|
||||||
|
style={{ padding: '8px 12px', borderRadius: 8, background: '#0a66c2', color: 'white', border: 'none', cursor: 'pointer', fontWeight: 800 }}
|
||||||
|
>
|
||||||
|
Run Google Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BrainstormFlow Component */}
|
||||||
|
<BrainstormFlow
|
||||||
|
brainstormVisible={brainstormVisible}
|
||||||
|
setBrainstormVisible={setBrainstormVisible}
|
||||||
|
brainstormStage={brainstormStage}
|
||||||
|
setBrainstormStage={setBrainstormStage}
|
||||||
|
loaderMessageIndex={loaderMessageIndex}
|
||||||
|
setLoaderMessageIndex={setLoaderMessageIndex}
|
||||||
|
aiSearchPrompts={aiSearchPrompts}
|
||||||
|
setAiSearchPrompts={setAiSearchPrompts}
|
||||||
|
selectedPrompt={selectedPrompt}
|
||||||
|
setSelectedPrompt={setSelectedPrompt}
|
||||||
|
searchResults={searchResults}
|
||||||
|
setSearchResults={setSearchResults}
|
||||||
|
ideas={ideas}
|
||||||
|
setIdeas={setIdeas}
|
||||||
|
isUsingCache={isUsingCache}
|
||||||
|
setIsUsingCache={setIsUsingCache}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ export { Header } from './Header';
|
|||||||
export { ContentEditor } from './ContentEditor';
|
export { ContentEditor } from './ContentEditor';
|
||||||
export { LoadingIndicator } from './LoadingIndicator';
|
export { LoadingIndicator } from './LoadingIndicator';
|
||||||
export { WelcomeMessage } from './WelcomeMessage';
|
export { WelcomeMessage } from './WelcomeMessage';
|
||||||
|
export { FeatureCarousel } from './FeatureCarousel';
|
||||||
|
export { InfoModals } from './InfoModals';
|
||||||
export { ProgressTracker } from './ProgressTracker';
|
export { ProgressTracker } from './ProgressTracker';
|
||||||
export { ContentRecommendations } from './ContentRecommendations';
|
export { ContentRecommendations } from './ContentRecommendations';
|
||||||
export { CopilotRecommendationsMessage } from './CopilotRecommendationsMessage';
|
export { CopilotRecommendationsMessage } from './CopilotRecommendationsMessage';
|
||||||
@@ -21,3 +23,7 @@ export { default as ImageGenerationDemo } from './ImageGenerationDemo';
|
|||||||
export { default as ImageGenerationTest } from './ImageGenerationTest';
|
export { default as ImageGenerationTest } from './ImageGenerationTest';
|
||||||
|
|
||||||
// Persona Integration Components - Now integrated into main LinkedInWriter
|
// Persona Integration Components - Now integrated into main LinkedInWriter
|
||||||
|
|
||||||
|
// Refactored Components
|
||||||
|
export { default as BrainstormFlow } from './BrainstormFlow';
|
||||||
|
export { default as CopilotActions } from './CopilotActions';
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
|
import { MainContentPreviewHeader, ContentPreviewHeaderWithModals } from './ContentPreviewHeaderComponents/index';
|
||||||
// Extend HTMLDivElement interface for custom tooltip properties
|
|
||||||
interface ExtendedDivElement extends HTMLDivElement {
|
|
||||||
_researchTooltip?: HTMLDivElement | null;
|
|
||||||
_citationsTooltip?: HTMLDivElement | null;
|
|
||||||
_searchQueriesTooltip?: HTMLDivElement | null;
|
|
||||||
_qualityTooltip?: HTMLDivElement | null;
|
|
||||||
_researchTooltipTimeout?: NodeJS.Timeout | null;
|
|
||||||
_qualityTooltipTimeout?: NodeJS.Timeout | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContentPreviewHeaderProps {
|
interface ContentPreviewHeaderProps {
|
||||||
researchSources?: any[];
|
researchSources?: any[];
|
||||||
@@ -23,828 +14,11 @@ interface ContentPreviewHeaderProps {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContentPreviewHeader: React.FC<ContentPreviewHeaderProps> = ({
|
// Main ContentPreviewHeader component - now just a wrapper that uses the extracted component
|
||||||
researchSources,
|
const ContentPreviewHeader: React.FC<ContentPreviewHeaderProps> = (props) => {
|
||||||
citations,
|
return <MainContentPreviewHeader {...props} />;
|
||||||
searchQueries,
|
|
||||||
qualityMetrics,
|
|
||||||
draft,
|
|
||||||
showPreview,
|
|
||||||
onPreviewToggle,
|
|
||||||
assistantOn,
|
|
||||||
onAssistantToggle,
|
|
||||||
topic
|
|
||||||
}) => {
|
|
||||||
const formatPercent = (v?: number) => typeof v === 'number' ? `${Math.round(v * 100)}%` : '—';
|
|
||||||
const getChipColor = (v?: number) => {
|
|
||||||
if (typeof v !== 'number') return '#6b7280';
|
|
||||||
if (v >= 0.8) return '#10b981';
|
|
||||||
if (v >= 0.6) return '#f59e0b';
|
|
||||||
return '#ef4444';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoize chips array to prevent infinite re-rendering
|
|
||||||
const chips = useMemo(() => {
|
|
||||||
const chipArray = qualityMetrics ? [
|
|
||||||
{ label: 'Overall', value: qualityMetrics.overall_score },
|
|
||||||
{ label: 'Accuracy', value: qualityMetrics.factual_accuracy },
|
|
||||||
{ label: 'Verification', value: qualityMetrics.source_verification },
|
|
||||||
{ label: 'Coverage', value: qualityMetrics.citation_coverage }
|
|
||||||
] : [];
|
|
||||||
|
|
||||||
console.log('🔍 [ContentPreviewHeader] Chips array created:', {
|
|
||||||
qualityMetrics: qualityMetrics,
|
|
||||||
chips: chipArray,
|
|
||||||
chipsLength: chipArray.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return chipArray;
|
|
||||||
}, [qualityMetrics]);
|
|
||||||
|
|
||||||
// Helper to build descriptive chip tooltip text
|
|
||||||
const chipDescriptions: Record<string, string> = {
|
|
||||||
Overall: 'Overall blends accuracy, verification and coverage into a single reliability score for this draft.',
|
|
||||||
Accuracy: 'Factual Accuracy estimates how likely statements are to be factually correct based on grounding signals.',
|
|
||||||
Verification: 'Source Verification reflects how well claims are linked to credible sources and whether citations match claims.',
|
|
||||||
Coverage: 'Citation Coverage indicates how much of the content is supported with citations. Higher is better.'
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
padding: '12px 16px',
|
|
||||||
background: '#e1f5fe',
|
|
||||||
borderBottom: '1px solid #b3e5fc',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#0277bd',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
|
||||||
<span>{topic ? `${topic} - LinkedIn Content Preview` : 'LinkedIn Content Preview'}</span>
|
|
||||||
|
|
||||||
{/* Research Chip with Hover Sub-chips */}
|
|
||||||
{((researchSources && researchSources.length > 0) || (citations && citations.length > 0) || (searchQueries && searchQueries.length > 0)) && (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
{/* Main Research Chip */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
|
|
||||||
border: '1px solid #0284c7',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '6px 14px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
boxShadow: '0 2px 8px rgba(14, 165, 233, 0.3)',
|
|
||||||
transform: 'translateZ(0)',
|
|
||||||
userSelect: 'none'
|
|
||||||
}}
|
|
||||||
title="Research data available. Hover to see sources, citations, and queries."
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
// Clear any existing timeout
|
|
||||||
const target = e.currentTarget as ExtendedDivElement;
|
|
||||||
if (target._researchTooltipTimeout) {
|
|
||||||
clearTimeout(target._researchTooltipTimeout);
|
|
||||||
target._researchTooltipTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and show research sub-chips tooltip
|
|
||||||
const tooltip = document.createElement('div');
|
|
||||||
tooltip.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
z-index: 100000;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #cfe9f7;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
|
||||||
padding: 16px;
|
|
||||||
max-width: 400px;
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
pointer-events: auto;
|
|
||||||
`;
|
|
||||||
|
|
||||||
let subChipsHtml = '<div style="margin-bottom: 12px; font-weight: 600; color: #0a66c2; font-size: 14px;">Research Data</div>';
|
|
||||||
|
|
||||||
// Add Sources sub-chip
|
|
||||||
if (researchSources && researchSources.length > 0) {
|
|
||||||
subChipsHtml += `
|
|
||||||
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #f0f9ff; border: 1px solid #0ea5e9; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
|
||||||
onmouseenter="this.style.background='#e0f2fe'; this.style.transform='scale(1.05)'"
|
|
||||||
onmouseleave="this.style.background='#f0f9ff'; this.style.transform='scale(1)'"
|
|
||||||
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showResearchSourcesModal', { detail: 'sources' }))">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; background: #10b981; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(16, 185, 129, 0.5);"></span>
|
|
||||||
Sources: ${researchSources.length}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Citations sub-chip
|
|
||||||
if (citations && citations.length > 0) {
|
|
||||||
subChipsHtml += `
|
|
||||||
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
|
||||||
onmouseenter="this.style.background='#fde68a'; this.style.transform='scale(1.05)'"
|
|
||||||
onmouseleave="this.style.background='#fef3c7'; this.style.transform='scale(1)'"
|
|
||||||
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showCitationsModal', { detail: 'citations' }))">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; background: #f59e0b; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(245, 158, 11, 0.5);"></span>
|
|
||||||
Citations: ${citations.length}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add Queries sub-chip
|
|
||||||
if (searchQueries && searchQueries.length > 0) {
|
|
||||||
subChipsHtml += `
|
|
||||||
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #f3e8ff; border: 1px solid #8b5cf6; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
|
||||||
onmouseenter="this.style.background='#e9d5ff'; this.style.transform='scale(1.05)'"
|
|
||||||
onmouseleave="this.style.background='#f3e8ff'; this.style.transform='scale(1)'"
|
|
||||||
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showSearchQueriesModal', { detail: 'queries' }))">
|
|
||||||
<span style="display: inline-block; width: 6px; height: 6px; background: #8b5cf6; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(139, 92, 246, 0.5);"></span>
|
|
||||||
Queries: ${searchQueries.length}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
tooltip.innerHTML = subChipsHtml;
|
|
||||||
|
|
||||||
// Add mouse events to tooltip to keep it visible
|
|
||||||
tooltip.addEventListener('mouseenter', () => {
|
|
||||||
if (target._researchTooltipTimeout) {
|
|
||||||
clearTimeout(target._researchTooltipTimeout);
|
|
||||||
target._researchTooltipTimeout = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tooltip.addEventListener('mouseleave', () => {
|
|
||||||
target._researchTooltipTimeout = setTimeout(() => {
|
|
||||||
if (tooltip.parentNode) {
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
tooltip.style.transform = 'translateY(-8px)';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (tooltip.parentNode) {
|
|
||||||
tooltip.remove();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
target._researchTooltip = null;
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(tooltip);
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
tooltip.style.left = Math.min(rect.left, window.innerWidth - 420) + 'px';
|
|
||||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
setTimeout(() => {
|
|
||||||
tooltip.style.opacity = '1';
|
|
||||||
tooltip.style.transform = 'translateY(0)';
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
target._researchTooltip = tooltip;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
const target = e.currentTarget as ExtendedDivElement;
|
|
||||||
if (target._researchTooltip) {
|
|
||||||
// Add delay before hiding to allow moving to tooltip
|
|
||||||
target._researchTooltipTimeout = setTimeout(() => {
|
|
||||||
const tooltip = target._researchTooltip;
|
|
||||||
if (tooltip && tooltip.parentNode) {
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
tooltip.style.transform = 'translateY(-8px)';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (tooltip.parentNode) {
|
|
||||||
tooltip.remove();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
target._researchTooltip = null;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseMove={(e) => {
|
|
||||||
// Keep tooltip visible when moving to sub-chips
|
|
||||||
const target = e.currentTarget as ExtendedDivElement;
|
|
||||||
if (target._researchTooltip) {
|
|
||||||
const tooltip = target._researchTooltip;
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
tooltip.style.left = Math.min(rect.left, window.innerWidth - 420) + 'px';
|
|
||||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
// Add hover effect to the chip itself
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(14, 165, 233, 0.4)';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
// Remove hover effect
|
|
||||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.3)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'rgba(255, 255, 255, 0.9)',
|
|
||||||
flexShrink: 0,
|
|
||||||
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
|
|
||||||
}} />
|
|
||||||
Research
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
|
||||||
{/* Quality Metrics Chip */}
|
|
||||||
{chips.length > 0 && (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
{/* Main Quality Metrics Chip */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
|
||||||
border: '1px solid #047857',
|
|
||||||
borderRadius: '999px',
|
|
||||||
padding: '6px 14px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '700',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
|
||||||
transform: 'translateZ(0)',
|
|
||||||
userSelect: 'none'
|
|
||||||
}}
|
|
||||||
title="Quality metrics available. Hover to see detailed progress bars and explanations."
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
// Clear any existing timeout
|
|
||||||
const target = e.currentTarget as ExtendedDivElement;
|
|
||||||
if (target._qualityTooltipTimeout) {
|
|
||||||
clearTimeout(target._qualityTooltipTimeout);
|
|
||||||
target._qualityTooltipTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and show quality metrics tooltip with circular progress bars
|
|
||||||
const tooltip = document.createElement('div');
|
|
||||||
tooltip.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
z-index: 100000;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #d1fae5;
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
|
|
||||||
padding: 24px;
|
|
||||||
max-width: 500px;
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
pointer-events: auto;
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Create circular progress bars for each metric
|
|
||||||
const createCircularProgress = (label: string, value: number, description: string) => {
|
|
||||||
const percentage = Math.round(value * 100);
|
|
||||||
const color = getChipColor(value);
|
|
||||||
const circumference = 2 * Math.PI * 45; // radius = 45
|
|
||||||
const strokeDasharray = circumference;
|
|
||||||
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding: 12px; background: #f8fafc; border-radius: 12px; border-left: 4px solid ${color};">
|
|
||||||
<div style="position: relative; width: 60px; height: 60px;">
|
|
||||||
<svg width="60" height="60" style="transform: rotate(-90deg);">
|
|
||||||
<circle cx="30" cy="30" r="45" stroke="#e5e7eb" stroke-width="6" fill="none"/>
|
|
||||||
<circle cx="30" cy="30" r="45" stroke="${color}" stroke-width="6" fill="none"
|
|
||||||
stroke-dasharray="${strokeDasharray}" stroke-dashoffset="${strokeDashoffset}"
|
|
||||||
style="transition: stroke-dashoffset 0.5s ease;"/>
|
|
||||||
</svg>
|
|
||||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-weight: 700; font-size: 14px; color: ${color};">
|
|
||||||
${percentage}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<div style="font-weight: 700; color: #1f2937; margin-bottom: 4px; font-size: 14px;">${label}</div>
|
|
||||||
<div style="color: #6b7280; line-height: 1.4; font-size: 11px;">${description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
let progressBarsHtml = '<div style="margin-bottom: 16px; font-weight: 700; color: #059669; font-size: 16px; text-align: center;">Quality Metrics</div>';
|
|
||||||
|
|
||||||
chips.forEach(chip => {
|
|
||||||
progressBarsHtml += createCircularProgress(
|
|
||||||
chip.label,
|
|
||||||
chip.value || 0,
|
|
||||||
chipDescriptions[chip.label] || ''
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
tooltip.innerHTML = progressBarsHtml;
|
|
||||||
|
|
||||||
// Add mouse events to tooltip to keep it visible
|
|
||||||
tooltip.addEventListener('mouseenter', () => {
|
|
||||||
if (target._qualityTooltipTimeout) {
|
|
||||||
clearTimeout(target._qualityTooltipTimeout);
|
|
||||||
target._qualityTooltipTimeout = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tooltip.addEventListener('mouseleave', () => {
|
|
||||||
target._qualityTooltipTimeout = setTimeout(() => {
|
|
||||||
if (tooltip.parentNode) {
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
tooltip.style.transform = 'translateY(-8px)';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (tooltip.parentNode) {
|
|
||||||
tooltip.remove();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
target._qualityTooltip = null;
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.body.appendChild(tooltip);
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
tooltip.style.left = Math.min(rect.left, window.innerWidth - 520) + 'px';
|
|
||||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
|
||||||
|
|
||||||
// Animate in
|
|
||||||
setTimeout(() => {
|
|
||||||
tooltip.style.opacity = '1';
|
|
||||||
tooltip.style.transform = 'translateY(0)';
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
target._qualityTooltip = tooltip;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
const target = e.currentTarget as ExtendedDivElement;
|
|
||||||
if (target._qualityTooltip) {
|
|
||||||
// Add delay before hiding to allow moving to tooltip
|
|
||||||
target._qualityTooltipTimeout = setTimeout(() => {
|
|
||||||
const tooltip = target._qualityTooltip;
|
|
||||||
if (tooltip && tooltip.parentNode) {
|
|
||||||
tooltip.style.opacity = '0';
|
|
||||||
tooltip.style.transform = 'translateY(-8px)';
|
|
||||||
setTimeout(() => {
|
|
||||||
if (tooltip.parentNode) {
|
|
||||||
tooltip.remove();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
target._qualityTooltip = null;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseMove={(e) => {
|
|
||||||
// Keep tooltip visible when moving to progress bars
|
|
||||||
const target = e.currentTarget as ExtendedDivElement;
|
|
||||||
if (target._qualityTooltip) {
|
|
||||||
const tooltip = target._qualityTooltip;
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
tooltip.style.left = Math.min(rect.left, window.innerWidth - 520) + 'px';
|
|
||||||
tooltip.style.top = (rect.bottom + 8) + 'px';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseOver={(e) => {
|
|
||||||
// Add hover effect to the chip itself
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 4px 16px rgba(16, 185, 129, 0.4)';
|
|
||||||
}}
|
|
||||||
onMouseOut={(e) => {
|
|
||||||
// Remove hover effect
|
|
||||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
width: '8px',
|
|
||||||
height: '8px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
background: 'rgba(255, 255, 255, 0.9)',
|
|
||||||
flexShrink: 0,
|
|
||||||
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
|
|
||||||
}} />
|
|
||||||
Quality Metrics
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
|
||||||
{draft.split(/\s+/).length} words • {Math.ceil(draft.split(/\s+/).length / 200)} min read
|
|
||||||
</span>
|
|
||||||
{/* Assistive Writing toggle */}
|
|
||||||
{onAssistantToggle && (
|
|
||||||
<label
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#666', cursor: 'pointer' }}
|
|
||||||
title="Assistive Writing: Get real-time AI-powered writing suggestions as you type. Uses Exa.ai for web research and Gemini for intelligent content generation. Automatically enables editing mode to allow typing and content modification."
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={assistantOn || false}
|
|
||||||
onChange={(e) => onAssistantToggle(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Assistive Writing
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#666', cursor: 'pointer' }}
|
|
||||||
title="Toggle preview visibility"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!showPreview}
|
|
||||||
onChange={() => onPreviewToggle()}
|
|
||||||
style={{ margin: 0 }}
|
|
||||||
/>
|
|
||||||
Hide Preview
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Research Sources Modal Component
|
|
||||||
const ResearchSourcesModal: React.FC<{ sources: any[]; isOpen: boolean; onClose: () => void }> = ({ sources, isOpen, onClose }) => {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
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: 1000000
|
|
||||||
}} onClick={onClose}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '24px',
|
|
||||||
maxWidth: '600px',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
|
||||||
}} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
||||||
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
|
||||||
Research Sources ({sources.length})
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
fontSize: '24px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#666',
|
|
||||||
padding: '0',
|
|
||||||
width: '30px',
|
|
||||||
height: '30px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
|
||||||
{sources && Array.isArray(sources) ? sources.map((source, idx) => (
|
|
||||||
<div key={idx} style={{
|
|
||||||
marginBottom: '16px',
|
|
||||||
padding: '16px',
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
borderRadius: '8px',
|
|
||||||
borderLeft: '4px solid #0a66c2'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontWeight: '600', marginBottom: '8px', color: '#0a66c2' }}>
|
|
||||||
{source.title || 'Untitled Source'}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: '#666', marginBottom: '12px', lineHeight: '1.5' }}>
|
|
||||||
{source.content || 'No description available'}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
|
||||||
{source.relevance_score && (
|
|
||||||
<span style={{
|
|
||||||
backgroundColor: '#eef6ff',
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#0a66c2'
|
|
||||||
}}>
|
|
||||||
Relevance: {Math.round(source.relevance_score * 100)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{source.credibility_score && (
|
|
||||||
<span style={{
|
|
||||||
backgroundColor: '#eef6ff',
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#0a66c2'
|
|
||||||
}}>
|
|
||||||
Credibility: {Math.round(source.credibility_score * 100)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{source.domain_authority && (
|
|
||||||
<span style={{
|
|
||||||
backgroundColor: '#eef6ff',
|
|
||||||
padding: '4px 8px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '11px',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#0a66c2'
|
|
||||||
}}>
|
|
||||||
Authority: {Math.round(source.domain_authority * 100)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
|
||||||
No research sources available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Citations Modal Component
|
|
||||||
const CitationsModal: React.FC<{ citations: any[]; isOpen: boolean; onClose: () => void }> = ({ citations, isOpen, onClose }) => {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
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: 1000000
|
|
||||||
}} onClick={onClose}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '24px',
|
|
||||||
maxWidth: '500px',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
|
||||||
}} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
||||||
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
|
||||||
Citations ({citations.length})
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
fontSize: '24px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#666',
|
|
||||||
padding: '0',
|
|
||||||
width: '30px',
|
|
||||||
height: '30px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
|
||||||
{citations && Array.isArray(citations) ? citations.map((citation, idx) => (
|
|
||||||
<div key={idx} style={{
|
|
||||||
marginBottom: '12px',
|
|
||||||
padding: '12px',
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
borderRadius: '6px',
|
|
||||||
borderLeft: '3px solid #f59e0b'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontWeight: '600', color: '#0a66c2', marginBottom: '4px' }}>
|
|
||||||
Citation {idx + 1}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>
|
|
||||||
Type: {citation.type || 'inline'}
|
|
||||||
</div>
|
|
||||||
{citation.reference && (
|
|
||||||
<div style={{ color: '#666', fontSize: '12px' }}>
|
|
||||||
Reference: {citation.reference}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
|
||||||
No citations available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search Queries Modal Component
|
|
||||||
const SearchQueriesModal: React.FC<{ queries: string[]; isOpen: boolean; onClose: () => void }> = ({ queries, isOpen, onClose }) => {
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
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: 1000000
|
|
||||||
}} onClick={onClose}>
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '24px',
|
|
||||||
maxWidth: '500px',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
|
||||||
}} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
|
||||||
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
|
||||||
Search Queries Used ({queries.length})
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
fontSize: '24px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: '#666',
|
|
||||||
padding: '0',
|
|
||||||
width: '30px',
|
|
||||||
height: '30px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
|
||||||
{queries && Array.isArray(queries) ? queries.map((query, idx) => (
|
|
||||||
<div key={idx} style={{
|
|
||||||
marginBottom: '12px',
|
|
||||||
padding: '12px',
|
|
||||||
backgroundColor: '#f8f9fa',
|
|
||||||
borderRadius: '6px',
|
|
||||||
borderLeft: '3px solid #8b5cf6'
|
|
||||||
}}>
|
|
||||||
<div style={{ fontWeight: '600', color: '#7c3aed', marginBottom: '6px' }}>
|
|
||||||
Query {idx + 1}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: '#374151', fontSize: '13px', lineHeight: '1.4' }}>
|
|
||||||
{query}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
|
||||||
No search queries available
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enhanced ContentPreviewHeader with Modal State
|
|
||||||
const ContentPreviewHeaderWithModals: React.FC<ContentPreviewHeaderProps> = (props) => {
|
|
||||||
const [showResearchSourcesModal, setShowResearchSourcesModal] = useState(false);
|
|
||||||
const [showCitationsModal, setShowCitationsModal] = useState(false);
|
|
||||||
const [showSearchQueriesModal, setShowSearchQueriesModal] = useState(false);
|
|
||||||
const [modalData, setModalData] = useState<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleShowResearchSourcesModal = (event: CustomEvent) => {
|
|
||||||
try {
|
|
||||||
const dataType = event.detail;
|
|
||||||
let data: any[] = [];
|
|
||||||
|
|
||||||
if (dataType === 'sources') {
|
|
||||||
data = props.researchSources || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalData(Array.isArray(data) ? data : []);
|
|
||||||
setShowResearchSourcesModal(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling research sources modal:', error);
|
|
||||||
setModalData([]);
|
|
||||||
setShowResearchSourcesModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowCitationsModal = (event: CustomEvent) => {
|
|
||||||
try {
|
|
||||||
const dataType = event.detail;
|
|
||||||
let data: any[] = [];
|
|
||||||
|
|
||||||
if (dataType === 'citations') {
|
|
||||||
data = props.citations || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalData(Array.isArray(data) ? data : []);
|
|
||||||
setShowCitationsModal(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling citations modal:', error);
|
|
||||||
setModalData([]);
|
|
||||||
setShowCitationsModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowSearchQueriesModal = (event: CustomEvent) => {
|
|
||||||
try {
|
|
||||||
const dataType = event.detail;
|
|
||||||
let data: any[] = [];
|
|
||||||
|
|
||||||
if (dataType === 'queries') {
|
|
||||||
data = props.searchQueries || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalData(Array.isArray(data) ? data : []);
|
|
||||||
setShowSearchQueriesModal(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling search queries modal:', error);
|
|
||||||
setModalData([]);
|
|
||||||
setShowSearchQueriesModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('showResearchSourcesModal', handleShowResearchSourcesModal as EventListener);
|
|
||||||
window.addEventListener('showCitationsModal', handleShowCitationsModal as EventListener);
|
|
||||||
window.addEventListener('showSearchQueriesModal', handleShowSearchQueriesModal as EventListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('showResearchSourcesModal', handleShowResearchSourcesModal as EventListener);
|
|
||||||
window.removeEventListener('showCitationsModal', handleShowCitationsModal as EventListener);
|
|
||||||
window.removeEventListener('showSearchQueriesModal', handleShowSearchQueriesModal as EventListener);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ContentPreviewHeader {...props} />
|
|
||||||
<ResearchSourcesModal
|
|
||||||
sources={modalData || []}
|
|
||||||
isOpen={showResearchSourcesModal}
|
|
||||||
onClose={() => setShowResearchSourcesModal(false)}
|
|
||||||
/>
|
|
||||||
<CitationsModal
|
|
||||||
citations={modalData || []}
|
|
||||||
isOpen={showCitationsModal}
|
|
||||||
onClose={() => setShowCitationsModal(false)}
|
|
||||||
/>
|
|
||||||
<SearchQueriesModal
|
|
||||||
queries={modalData || []}
|
|
||||||
isOpen={showSearchQueriesModal}
|
|
||||||
onClose={() => setShowSearchQueriesModal(false)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export both the main component and the enhanced version with modals
|
||||||
export default ContentPreviewHeader;
|
export default ContentPreviewHeader;
|
||||||
export { ContentPreviewHeader, ContentPreviewHeaderWithModals };
|
export { ContentPreviewHeader, ContentPreviewHeaderWithModals };
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import MainContentPreviewHeader from './MainContentPreviewHeader';
|
||||||
|
|
||||||
|
interface ContentPreviewHeaderProps {
|
||||||
|
researchSources?: any[];
|
||||||
|
citations?: any[];
|
||||||
|
searchQueries?: string[];
|
||||||
|
qualityMetrics?: any;
|
||||||
|
draft: string;
|
||||||
|
showPreview: boolean;
|
||||||
|
onPreviewToggle: () => void;
|
||||||
|
assistantOn?: boolean;
|
||||||
|
onAssistantToggle?: (enabled: boolean) => void;
|
||||||
|
topic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Research Sources Modal Component
|
||||||
|
const ResearchSourcesModal: React.FC<{ sources: any[]; isOpen: boolean; onClose: () => void }> = ({ sources, isOpen, onClose }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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: 1000000
|
||||||
|
}} onClick={onClose}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
||||||
|
}} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
||||||
|
Research Sources ({sources.length})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#666',
|
||||||
|
padding: '0',
|
||||||
|
width: '30px',
|
||||||
|
height: '30px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
{sources && Array.isArray(sources) ? sources.map((source, idx) => (
|
||||||
|
<div key={idx} style={{
|
||||||
|
marginBottom: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '8px',
|
||||||
|
borderLeft: '4px solid #0a66c2'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: '600', marginBottom: '8px', color: '#0a66c2' }}>
|
||||||
|
{source.title || 'Untitled Source'}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#666', marginBottom: '12px', lineHeight: '1.5' }}>
|
||||||
|
{source.content || 'No description available'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||||
|
{source.relevance_score && (
|
||||||
|
<span style={{
|
||||||
|
backgroundColor: '#eef6ff',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0a66c2'
|
||||||
|
}}>
|
||||||
|
Relevance: {Math.round(source.relevance_score * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{source.credibility_score && (
|
||||||
|
<span style={{
|
||||||
|
backgroundColor: '#eef6ff',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0a66c2'
|
||||||
|
}}>
|
||||||
|
Credibility: {Math.round(source.credibility_score * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{source.domain_authority && (
|
||||||
|
<span style={{
|
||||||
|
backgroundColor: '#eef6ff',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0a66c2'
|
||||||
|
}}>
|
||||||
|
Authority: {Math.round(source.domain_authority * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||||
|
No research sources available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Citations Modal Component
|
||||||
|
const CitationsModal: React.FC<{ citations: any[]; isOpen: boolean; onClose: () => void }> = ({ citations, isOpen, onClose }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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: 1000000
|
||||||
|
}} onClick={onClose}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
||||||
|
}} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
||||||
|
Citations ({citations.length})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#666',
|
||||||
|
padding: '0',
|
||||||
|
width: '30px',
|
||||||
|
height: '30px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
{citations && Array.isArray(citations) ? citations.map((citation, idx) => (
|
||||||
|
<div key={idx} style={{
|
||||||
|
marginBottom: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '6px',
|
||||||
|
borderLeft: '3px solid #f59e0b'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: '600', color: '#0a66c2', marginBottom: '4px' }}>
|
||||||
|
Citation {idx + 1}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#666', fontSize: '12px', marginBottom: '4px' }}>
|
||||||
|
Type: {citation.type || 'inline'}
|
||||||
|
</div>
|
||||||
|
{citation.reference && (
|
||||||
|
<div style={{ color: '#666', fontSize: '12px' }}>
|
||||||
|
Reference: {citation.reference}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||||
|
No citations available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search Queries Modal Component
|
||||||
|
const SearchQueriesModal: React.FC<{ queries: string[]; isOpen: boolean; onClose: () => void }> = ({ queries, isOpen, onClose }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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: 1000000
|
||||||
|
}} onClick={onClose}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)'
|
||||||
|
}} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h3 style={{ margin: 0, color: '#0a66c2', fontSize: '18px', fontWeight: '600' }}>
|
||||||
|
Search Queries Used ({queries.length})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#666',
|
||||||
|
padding: '0',
|
||||||
|
width: '30px',
|
||||||
|
height: '30px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||||
|
{queries && Array.isArray(queries) ? queries.map((query, idx) => (
|
||||||
|
<div key={idx} style={{
|
||||||
|
marginBottom: '12px',
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
borderRadius: '6px',
|
||||||
|
borderLeft: '3px solid #8b5cf6'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: '600', color: '#7c3aed', marginBottom: '6px' }}>
|
||||||
|
Query {idx + 1}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#374151', fontSize: '13px', lineHeight: '1.4' }}>
|
||||||
|
{query}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||||
|
No search queries available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enhanced ContentPreviewHeader with Modal State
|
||||||
|
const ContentPreviewHeaderWithModals: React.FC<ContentPreviewHeaderProps> = (props) => {
|
||||||
|
const [showResearchSourcesModal, setShowResearchSourcesModal] = useState(false);
|
||||||
|
const [showCitationsModal, setShowCitationsModal] = useState(false);
|
||||||
|
const [showSearchQueriesModal, setShowSearchQueriesModal] = useState(false);
|
||||||
|
const [modalData, setModalData] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleShowResearchSourcesModal = (event: CustomEvent) => {
|
||||||
|
try {
|
||||||
|
const dataType = event.detail;
|
||||||
|
let data: any[] = [];
|
||||||
|
|
||||||
|
if (dataType === 'sources') {
|
||||||
|
data = props.researchSources || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalData(Array.isArray(data) ? data : []);
|
||||||
|
setShowResearchSourcesModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling research sources modal:', error);
|
||||||
|
setModalData([]);
|
||||||
|
setShowResearchSourcesModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowCitationsModal = (event: CustomEvent) => {
|
||||||
|
try {
|
||||||
|
const dataType = event.detail;
|
||||||
|
let data: any[] = [];
|
||||||
|
|
||||||
|
if (dataType === 'citations') {
|
||||||
|
data = props.citations || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalData(Array.isArray(data) ? data : []);
|
||||||
|
setShowCitationsModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling citations modal:', error);
|
||||||
|
setModalData([]);
|
||||||
|
setShowCitationsModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowSearchQueriesModal = (event: CustomEvent) => {
|
||||||
|
try {
|
||||||
|
const dataType = event.detail;
|
||||||
|
let data: any[] = [];
|
||||||
|
|
||||||
|
if (dataType === 'queries') {
|
||||||
|
data = props.searchQueries || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalData(Array.isArray(data) ? data : []);
|
||||||
|
setShowSearchQueriesModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling search queries modal:', error);
|
||||||
|
setModalData([]);
|
||||||
|
setShowSearchQueriesModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('showResearchSourcesModal', handleShowResearchSourcesModal as EventListener);
|
||||||
|
window.addEventListener('showCitationsModal', handleShowCitationsModal as EventListener);
|
||||||
|
window.addEventListener('showSearchQueriesModal', handleShowSearchQueriesModal as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('showResearchSourcesModal', handleShowResearchSourcesModal as EventListener);
|
||||||
|
window.removeEventListener('showCitationsModal', handleShowCitationsModal as EventListener);
|
||||||
|
window.removeEventListener('showSearchQueriesModal', handleShowSearchQueriesModal as EventListener);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MainContentPreviewHeader {...props} />
|
||||||
|
<ResearchSourcesModal
|
||||||
|
sources={modalData || []}
|
||||||
|
isOpen={showResearchSourcesModal}
|
||||||
|
onClose={() => setShowResearchSourcesModal(false)}
|
||||||
|
/>
|
||||||
|
<CitationsModal
|
||||||
|
citations={modalData || []}
|
||||||
|
isOpen={showCitationsModal}
|
||||||
|
onClose={() => setShowCitationsModal(false)}
|
||||||
|
/>
|
||||||
|
<SearchQueriesModal
|
||||||
|
queries={modalData || []}
|
||||||
|
isOpen={showSearchQueriesModal}
|
||||||
|
onClose={() => setShowSearchQueriesModal(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContentPreviewHeaderWithModals;
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import PersonaChip from './PersonaChip';
|
||||||
|
|
||||||
|
// Extend HTMLDivElement interface for custom tooltip properties
|
||||||
|
interface ExtendedDivElement extends HTMLDivElement {
|
||||||
|
_researchTooltip?: HTMLDivElement | null;
|
||||||
|
_citationsTooltip?: HTMLDivElement | null;
|
||||||
|
_searchQueriesTooltip?: HTMLDivElement | null;
|
||||||
|
_qualityTooltip?: HTMLDivElement | null;
|
||||||
|
_researchTooltipTimeout?: NodeJS.Timeout | null;
|
||||||
|
_qualityTooltipTimeout?: NodeJS.Timeout | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MainContentPreviewHeaderProps {
|
||||||
|
researchSources?: any[];
|
||||||
|
citations?: any[];
|
||||||
|
searchQueries?: string[];
|
||||||
|
qualityMetrics?: any;
|
||||||
|
draft: string;
|
||||||
|
showPreview: boolean;
|
||||||
|
onPreviewToggle: () => void;
|
||||||
|
assistantOn?: boolean;
|
||||||
|
onAssistantToggle?: (enabled: boolean) => void;
|
||||||
|
topic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MainContentPreviewHeader: React.FC<MainContentPreviewHeaderProps> = ({
|
||||||
|
researchSources,
|
||||||
|
citations,
|
||||||
|
searchQueries,
|
||||||
|
qualityMetrics,
|
||||||
|
draft,
|
||||||
|
showPreview,
|
||||||
|
onPreviewToggle,
|
||||||
|
assistantOn,
|
||||||
|
onAssistantToggle,
|
||||||
|
topic
|
||||||
|
}) => {
|
||||||
|
const formatPercent = (v?: number) => typeof v === 'number' ? `${Math.round(v * 100)}%` : '—';
|
||||||
|
const getChipColor = (v?: number) => {
|
||||||
|
if (typeof v !== 'number') return '#6b7280';
|
||||||
|
if (v >= 0.8) return '#10b981';
|
||||||
|
if (v >= 0.6) return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Memoize chips array to prevent infinite re-rendering
|
||||||
|
const chips = useMemo(() => {
|
||||||
|
const chipArray = qualityMetrics ? [
|
||||||
|
{ label: 'Overall', value: qualityMetrics.overall_score },
|
||||||
|
{ label: 'Accuracy', value: qualityMetrics.factual_accuracy },
|
||||||
|
{ label: 'Verification', value: qualityMetrics.source_verification },
|
||||||
|
{ label: 'Coverage', value: qualityMetrics.citation_coverage }
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
console.log('🔍 [ContentPreviewHeader] Chips array created:', {
|
||||||
|
qualityMetrics: qualityMetrics,
|
||||||
|
chips: chipArray,
|
||||||
|
chipsLength: chipArray.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return chipArray;
|
||||||
|
}, [qualityMetrics]);
|
||||||
|
|
||||||
|
// Helper to build descriptive chip tooltip text
|
||||||
|
const chipDescriptions: Record<string, string> = {
|
||||||
|
Overall: 'Overall blends accuracy, verification and coverage into a single reliability score for this draft.',
|
||||||
|
Accuracy: 'Factual Accuracy estimates how likely statements are to be factually correct based on grounding signals.',
|
||||||
|
Verification: 'Source Verification reflects how well claims are linked to credible sources and whether citations match claims.',
|
||||||
|
Coverage: 'Citation Coverage indicates how much of the content is supported with citations. Higher is better.'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#e1f5fe',
|
||||||
|
borderBottom: '1px solid #b3e5fc',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#0277bd',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<span>{topic ? `${topic} - LinkedIn Content Preview` : 'LinkedIn Content Preview'}</span>
|
||||||
|
|
||||||
|
{/* Persona Chip */}
|
||||||
|
<PersonaChip
|
||||||
|
platform="linkedin"
|
||||||
|
userId={1}
|
||||||
|
onPersonaUpdate={(personaData) => {
|
||||||
|
console.log('Persona updated:', personaData);
|
||||||
|
// You can add additional logic here to handle persona updates
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Research Chip with Hover Sub-chips */}
|
||||||
|
{((researchSources && researchSources.length > 0) || (citations && citations.length > 0) || (searchQueries && searchQueries.length > 0)) && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* Main Research Chip */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%)',
|
||||||
|
border: '1px solid #0284c7',
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
boxShadow: '0 2px 8px rgba(14, 165, 233, 0.3)',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
title="Research data available. Hover to see sources, citations, and queries."
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
// Clear any existing timeout
|
||||||
|
const target = e.currentTarget as ExtendedDivElement;
|
||||||
|
if (target._researchTooltipTimeout) {
|
||||||
|
clearTimeout(target._researchTooltipTimeout);
|
||||||
|
target._researchTooltipTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and show research sub-chips tooltip
|
||||||
|
const tooltip = document.createElement('div');
|
||||||
|
tooltip.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100000;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #cfe9f7;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
|
padding: 16px;
|
||||||
|
max-width: 400px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
let subChipsHtml = '<div style="margin-bottom: 12px; font-weight: 600; color: #0a66c2; font-size: 14px;">Research Data</div>';
|
||||||
|
|
||||||
|
// Add Sources sub-chip
|
||||||
|
if (researchSources && researchSources.length > 0) {
|
||||||
|
subChipsHtml += `
|
||||||
|
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #f0f9ff; border: 1px solid #0ea5e9; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
||||||
|
onmouseenter="this.style.background='#e0f2fe'; this.style.transform='scale(1.05)'"
|
||||||
|
onmouseleave="this.style.background='#f0f9ff'; this.style.transform='scale(1)'"
|
||||||
|
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showResearchSourcesModal', { detail: 'sources' }))">
|
||||||
|
<span style="display: inline-block; width: 6px; height: 6px; background: #10b981; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(16, 185, 129, 0.5);"></span>
|
||||||
|
Sources: ${researchSources.length}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Citations sub-chip
|
||||||
|
if (citations && citations.length > 0) {
|
||||||
|
subChipsHtml += `
|
||||||
|
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
||||||
|
onmouseenter="this.style.background='#fde68a'; this.style.transform='scale(1.05)'"
|
||||||
|
onmouseleave="this.style.background='#fef3c7'; this.style.transform='scale(1)'"
|
||||||
|
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showCitationsModal', { detail: 'citations' }))">
|
||||||
|
<span style="display: inline-block; width: 6px; height: 6px; background: #f59e0b; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(245, 158, 11, 0.5);"></span>
|
||||||
|
Citations: ${citations.length}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Queries sub-chip
|
||||||
|
if (searchQueries && searchQueries.length > 0) {
|
||||||
|
subChipsHtml += `
|
||||||
|
<div style="display: inline-block; margin: 3px; padding: 6px 12px; background: #f3e8ff; border: 1px solid #8b5cf6; border-radius: 16px; font-size: 11px; cursor: pointer; font-weight: 600; transition: all 0.2s ease;"
|
||||||
|
onmouseenter="this.style.background='#e9d5ff'; this.style.transform='scale(1.05)'"
|
||||||
|
onmouseleave="this.style.background='#f3e8ff'; this.style.transform='scale(1)'"
|
||||||
|
onclick="event.stopPropagation(); window.dispatchEvent(new CustomEvent('showSearchQueriesModal', { detail: 'queries' }))">
|
||||||
|
<span style="display: inline-block; width: 6px; height: 6px; background: #8b5cf6; border-radius: 50%; margin-right: 6px; box-shadow: 0 0 4px rgba(139, 92, 246, 0.5);"></span>
|
||||||
|
Queries: ${searchQueries.length}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.innerHTML = subChipsHtml;
|
||||||
|
|
||||||
|
// Add mouse events to tooltip to keep it visible
|
||||||
|
tooltip.addEventListener('mouseenter', () => {
|
||||||
|
if (target._researchTooltipTimeout) {
|
||||||
|
clearTimeout(target._researchTooltipTimeout);
|
||||||
|
target._researchTooltipTimeout = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tooltip.addEventListener('mouseleave', () => {
|
||||||
|
target._researchTooltipTimeout = setTimeout(() => {
|
||||||
|
if (tooltip.parentNode) {
|
||||||
|
tooltip.style.opacity = '0';
|
||||||
|
tooltip.style.transform = 'translateY(-8px)';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (tooltip.parentNode) {
|
||||||
|
tooltip.remove();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
target._researchTooltip = null;
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
tooltip.style.left = Math.min(rect.left, window.innerWidth - 420) + 'px';
|
||||||
|
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
setTimeout(() => {
|
||||||
|
tooltip.style.opacity = '1';
|
||||||
|
tooltip.style.transform = 'translateY(0)';
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
target._researchTooltip = tooltip;
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
const target = e.currentTarget as ExtendedDivElement;
|
||||||
|
if (target._researchTooltip) {
|
||||||
|
// Add delay before hiding to allow moving to tooltip
|
||||||
|
target._researchTooltipTimeout = setTimeout(() => {
|
||||||
|
const tooltip = target._researchTooltip;
|
||||||
|
if (tooltip && tooltip.parentNode) {
|
||||||
|
tooltip.style.opacity = '0';
|
||||||
|
tooltip.style.transform = 'translateY(-8px)';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (tooltip.parentNode) {
|
||||||
|
tooltip.remove();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
target._researchTooltip = null;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
// Keep tooltip visible when moving to sub-chips
|
||||||
|
const target = e.currentTarget as ExtendedDivElement;
|
||||||
|
if (target._researchTooltip) {
|
||||||
|
const tooltip = target._researchTooltip;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
tooltip.style.left = Math.min(rect.left, window.innerWidth - 420) + 'px';
|
||||||
|
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
// Add hover effect to the chip itself
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 16px rgba(14, 165, 233, 0.4)';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
// Remove hover effect
|
||||||
|
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.3)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
|
||||||
|
}} />
|
||||||
|
Research
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||||
|
{/* Quality Metrics Chip */}
|
||||||
|
{chips.length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{/* Main Quality Metrics Chip */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||||
|
border: '1px solid #047857',
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
title="Quality metrics available. Hover to see detailed progress bars and explanations."
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
// Clear any existing timeout
|
||||||
|
const target = e.currentTarget as ExtendedDivElement;
|
||||||
|
if (target._qualityTooltipTimeout) {
|
||||||
|
clearTimeout(target._qualityTooltipTimeout);
|
||||||
|
target._qualityTooltipTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and show quality metrics tooltip with circular progress bars
|
||||||
|
const tooltip = document.createElement('div');
|
||||||
|
tooltip.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100000;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #d1fae5;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 500px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create circular progress bars for each metric
|
||||||
|
const createCircularProgress = (label: string, value: number, description: string) => {
|
||||||
|
const percentage = Math.round(value * 100);
|
||||||
|
const color = getChipColor(value);
|
||||||
|
const circumference = 2 * Math.PI * 45; // radius = 45
|
||||||
|
const strokeDasharray = circumference;
|
||||||
|
const strokeDashoffset = circumference - (percentage / 100) * circumference;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding: 12px; background: #f8fafc; border-radius: 12px; border-left: 4px solid ${color};">
|
||||||
|
<div style="position: relative; width: 60px; height: 60px;">
|
||||||
|
<svg width="60" height="60" style="transform: rotate(-90deg);">
|
||||||
|
<circle cx="30" cy="30" r="45" stroke="#e5e7eb" stroke-width="6" fill="none"/>
|
||||||
|
<circle cx="30" cy="30" r="45" stroke="${color}" stroke-width="6" fill="none"
|
||||||
|
stroke-dasharray="${strokeDasharray}" stroke-dashoffset="${strokeDashoffset}"
|
||||||
|
style="transition: stroke-dashoffset 0.5s ease;"/>
|
||||||
|
</svg>
|
||||||
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-weight: 700; font-size: 14px; color: ${color};">
|
||||||
|
${percentage}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="font-weight: 700; color: #1f2937; margin-bottom: 4px; font-size: 14px;">${label}</div>
|
||||||
|
<div style="color: #6b7280; line-height: 1.4; font-size: 11px;">${description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let progressBarsHtml = '<div style="margin-bottom: 16px; font-weight: 700; color: #059669; font-size: 16px; text-align: center;">Quality Metrics</div>';
|
||||||
|
|
||||||
|
chips.forEach(chip => {
|
||||||
|
progressBarsHtml += createCircularProgress(
|
||||||
|
chip.label,
|
||||||
|
chip.value || 0,
|
||||||
|
chipDescriptions[chip.label] || ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tooltip.innerHTML = progressBarsHtml;
|
||||||
|
|
||||||
|
// Add mouse events to tooltip to keep it visible
|
||||||
|
tooltip.addEventListener('mouseenter', () => {
|
||||||
|
if (target._qualityTooltipTimeout) {
|
||||||
|
clearTimeout(target._qualityTooltipTimeout);
|
||||||
|
target._qualityTooltipTimeout = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tooltip.addEventListener('mouseleave', () => {
|
||||||
|
target._qualityTooltipTimeout = setTimeout(() => {
|
||||||
|
if (tooltip.parentNode) {
|
||||||
|
tooltip.style.opacity = '0';
|
||||||
|
tooltip.style.transform = 'translateY(-8px)';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (tooltip.parentNode) {
|
||||||
|
tooltip.remove();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
target._qualityTooltip = null;
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
tooltip.style.left = Math.min(rect.left, window.innerWidth - 520) + 'px';
|
||||||
|
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||||
|
|
||||||
|
// Animate in
|
||||||
|
setTimeout(() => {
|
||||||
|
tooltip.style.opacity = '1';
|
||||||
|
tooltip.style.transform = 'translateY(0)';
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
target._qualityTooltip = tooltip;
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
const target = e.currentTarget as ExtendedDivElement;
|
||||||
|
if (target._qualityTooltip) {
|
||||||
|
// Add delay before hiding to allow moving to tooltip
|
||||||
|
target._qualityTooltipTimeout = setTimeout(() => {
|
||||||
|
const tooltip = target._qualityTooltip;
|
||||||
|
if (tooltip && tooltip.parentNode) {
|
||||||
|
tooltip.style.opacity = '0';
|
||||||
|
tooltip.style.transform = 'translateY(-8px)';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (tooltip.parentNode) {
|
||||||
|
tooltip.remove();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
target._qualityTooltip = null;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
// Keep tooltip visible when moving to progress bars
|
||||||
|
const target = e.currentTarget as ExtendedDivElement;
|
||||||
|
if (target._qualityTooltip) {
|
||||||
|
const tooltip = target._qualityTooltip;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
tooltip.style.left = Math.min(rect.left, window.innerWidth - 520) + 'px';
|
||||||
|
tooltip.style.top = (rect.bottom + 8) + 'px';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
// Add hover effect to the chip itself
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 16px rgba(16, 185, 129, 0.4)';
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
// Remove hover effect
|
||||||
|
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
|
||||||
|
}} />
|
||||||
|
Quality Metrics
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '10px', opacity: 0.8 }}>
|
||||||
|
{draft.split(/\s+/).length} words • {Math.ceil(draft.split(/\s+/).length / 200)} min read
|
||||||
|
</span>
|
||||||
|
{/* Assistive Writing toggle */}
|
||||||
|
{onAssistantToggle && (
|
||||||
|
<label
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#666', cursor: 'pointer' }}
|
||||||
|
title="Assistive Writing: Get real-time AI-powered writing suggestions as you type. Uses Exa.ai for web research and Gemini for intelligent content generation. Automatically enables editing mode to allow typing and content modification."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={assistantOn || false}
|
||||||
|
onChange={(e) => onAssistantToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Assistive Writing
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#666', cursor: 'pointer' }}
|
||||||
|
title="Toggle preview visibility"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!showPreview}
|
||||||
|
onChange={() => onPreviewToggle()}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
Hide Preview
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainContentPreviewHeader;
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import PersonaEditorModal from './PersonaEditorModal';
|
||||||
|
|
||||||
|
interface PersonaData {
|
||||||
|
id?: number;
|
||||||
|
user_id?: number;
|
||||||
|
persona_name: string;
|
||||||
|
archetype: string;
|
||||||
|
core_belief: string;
|
||||||
|
brand_voice_description: string;
|
||||||
|
linguistic_fingerprint: any;
|
||||||
|
platform_adaptations: any;
|
||||||
|
confidence_score: number;
|
||||||
|
ai_analysis_version: string;
|
||||||
|
platform_type: string;
|
||||||
|
sentence_metrics: any;
|
||||||
|
lexical_features: any;
|
||||||
|
rhetorical_devices: any;
|
||||||
|
tonal_range: any;
|
||||||
|
stylistic_constraints: any;
|
||||||
|
content_format_rules: any;
|
||||||
|
engagement_patterns: any;
|
||||||
|
posting_frequency: any;
|
||||||
|
content_types: any;
|
||||||
|
platform_best_practices: any;
|
||||||
|
algorithm_considerations: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonaChipProps {
|
||||||
|
platform: string;
|
||||||
|
userId?: number;
|
||||||
|
onPersonaUpdate?: (personaData: PersonaData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersonaChip: React.FC<PersonaChipProps> = ({
|
||||||
|
platform,
|
||||||
|
userId = 1,
|
||||||
|
onPersonaUpdate
|
||||||
|
}) => {
|
||||||
|
const [personaData, setPersonaData] = useState<PersonaData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch persona data
|
||||||
|
const fetchPersonaData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch core persona list (take most recent active) and platform-specific details
|
||||||
|
const [coreRes, platformRes] = await Promise.all([
|
||||||
|
fetch(`/api/personas/user/${userId}`),
|
||||||
|
fetch(`/api/personas/platform/${platform}?user_id=${userId}`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (coreRes.ok && platformRes.ok) {
|
||||||
|
const coreList = await coreRes.json();
|
||||||
|
const platformData = await platformRes.json();
|
||||||
|
const core = (coreList?.personas && coreList.personas.length > 0) ? coreList.personas[0] : {};
|
||||||
|
|
||||||
|
// Merge core + platform fields for editor convenience
|
||||||
|
setPersonaData({
|
||||||
|
id: core.id,
|
||||||
|
user_id: core.user_id,
|
||||||
|
persona_name: core.persona_name,
|
||||||
|
archetype: core.archetype,
|
||||||
|
core_belief: core.core_belief,
|
||||||
|
brand_voice_description: core.brand_voice_description,
|
||||||
|
linguistic_fingerprint: core.linguistic_fingerprint,
|
||||||
|
platform_adaptations: core.platform_adaptations,
|
||||||
|
confidence_score: core.confidence_score,
|
||||||
|
ai_analysis_version: core.ai_analysis_version,
|
||||||
|
platform_type: platform,
|
||||||
|
sentence_metrics: platformData?.sentence_metrics,
|
||||||
|
lexical_features: platformData?.lexical_features,
|
||||||
|
rhetorical_devices: platformData?.rhetorical_devices,
|
||||||
|
tonal_range: platformData?.tonal_range,
|
||||||
|
stylistic_constraints: platformData?.stylistic_constraints,
|
||||||
|
content_format_rules: platformData?.content_format_rules,
|
||||||
|
engagement_patterns: platformData?.engagement_patterns,
|
||||||
|
posting_frequency: platformData?.posting_frequency,
|
||||||
|
content_types: platformData?.content_types,
|
||||||
|
platform_best_practices: platformData?.platform_best_practices,
|
||||||
|
algorithm_considerations: platformData?.algorithm_considerations,
|
||||||
|
} as any);
|
||||||
|
} else {
|
||||||
|
setError('No persona found for this platform');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load persona data');
|
||||||
|
console.error('Error fetching persona:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPersonaData();
|
||||||
|
}, [platform, userId]);
|
||||||
|
|
||||||
|
const handleSavePersona = async (data: PersonaData, saveToDatabase: boolean) => {
|
||||||
|
try {
|
||||||
|
if (saveToDatabase) {
|
||||||
|
// Save core persona simple fields
|
||||||
|
if (data.id) {
|
||||||
|
const corePayload: any = {
|
||||||
|
persona_name: data.persona_name,
|
||||||
|
archetype: data.archetype,
|
||||||
|
core_belief: data.core_belief,
|
||||||
|
brand_voice_description: data.brand_voice_description,
|
||||||
|
linguistic_fingerprint: data.linguistic_fingerprint,
|
||||||
|
platform_adaptations: data.platform_adaptations,
|
||||||
|
};
|
||||||
|
|
||||||
|
const coreRes = await fetch(`/api/personas/${data.id}?user_id=${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(corePayload)
|
||||||
|
});
|
||||||
|
if (!coreRes.ok) throw new Error('Failed to update core persona');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save platform persona fields
|
||||||
|
const platformPayload: any = {
|
||||||
|
sentence_metrics: data.sentence_metrics,
|
||||||
|
lexical_features: data.lexical_features,
|
||||||
|
rhetorical_devices: data.rhetorical_devices,
|
||||||
|
tonal_range: data.tonal_range,
|
||||||
|
stylistic_constraints: data.stylistic_constraints,
|
||||||
|
content_format_rules: data.content_format_rules,
|
||||||
|
engagement_patterns: data.engagement_patterns,
|
||||||
|
posting_frequency: data.posting_frequency,
|
||||||
|
content_types: data.content_types,
|
||||||
|
platform_best_practices: data.platform_best_practices,
|
||||||
|
algorithm_considerations: data.algorithm_considerations,
|
||||||
|
};
|
||||||
|
|
||||||
|
const platRes = await fetch(`/api/personas/platform/${platform}?user_id=${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(platformPayload)
|
||||||
|
});
|
||||||
|
if (!platRes.ok) throw new Error('Failed to update platform persona');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setPersonaData(data);
|
||||||
|
|
||||||
|
// Notify parent component
|
||||||
|
if (onPersonaUpdate) {
|
||||||
|
onPersonaUpdate(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Persona updated:', saveToDatabase ? 'saved to database' : 'session only');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving persona:', err);
|
||||||
|
setError('Failed to save persona changes');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPersonaColor = (confidence?: number) => {
|
||||||
|
if (!confidence) return '#6b7280';
|
||||||
|
if (confidence >= 0.8) return '#10b981';
|
||||||
|
if (confidence >= 0.6) return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPersonaIcon = (archetype?: string) => {
|
||||||
|
if (!archetype) return '👤';
|
||||||
|
|
||||||
|
const archetypeIcons: Record<string, string> = {
|
||||||
|
'pragmatic futurist': '🔮',
|
||||||
|
'thoughtful educator': '📚',
|
||||||
|
'innovative leader': '🚀',
|
||||||
|
'analytical expert': '🔍',
|
||||||
|
'creative storyteller': '✨',
|
||||||
|
'strategic advisor': '🎯',
|
||||||
|
'authentic connector': '🤝',
|
||||||
|
'data-driven optimist': '📊'
|
||||||
|
};
|
||||||
|
|
||||||
|
const lowerArchetype = archetype.toLowerCase();
|
||||||
|
for (const [key, icon] of Object.entries(archetypeIcons)) {
|
||||||
|
if (lowerArchetype.includes(key)) {
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '👤';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#6b7280',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#9ca3af',
|
||||||
|
animation: 'pulse 2s infinite'
|
||||||
|
}} />
|
||||||
|
Loading Persona...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !personaData) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%)',
|
||||||
|
border: '1px solid #fca5a5',
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#dc2626',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={() => fetchPersonaData()}
|
||||||
|
title="Click to retry loading persona data"
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#ef4444'
|
||||||
|
}} />
|
||||||
|
No Persona
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidence = personaData.confidence_score || 0;
|
||||||
|
const confidenceColor = getPersonaColor(confidence);
|
||||||
|
|
||||||
|
// Debug: Log the confidence score to see what's being stored
|
||||||
|
console.log('PersonaChip confidence_score:', personaData.confidence_score, 'processed:', confidence);
|
||||||
|
const personaIcon = getPersonaIcon(personaData.archetype);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${confidenceColor} 0%, ${confidenceColor}dd 100%)`,
|
||||||
|
border: `1px solid ${confidenceColor}`,
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
boxShadow: `0 2px 8px ${confidenceColor}40`,
|
||||||
|
transform: 'translateZ(0)',
|
||||||
|
userSelect: 'none'
|
||||||
|
}}
|
||||||
|
title={`${personaData.persona_name} - ${personaData.archetype || 'No archetype'} (${Math.round(confidence * 100)}% confidence). Click to edit.`}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-2px) scale(1.05)';
|
||||||
|
e.currentTarget.style.boxShadow = `0 4px 16px ${confidenceColor}60`;
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||||
|
e.currentTarget.style.boxShadow = `0 2px 8px ${confidenceColor}40`;
|
||||||
|
}}
|
||||||
|
onClick={() => setShowEditor(true)}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
{personaIcon}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
flexShrink: 0,
|
||||||
|
boxShadow: '0 0 6px rgba(255, 255, 255, 0.5)'
|
||||||
|
}} />
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{personaData.persona_name || 'Untitled Persona'}
|
||||||
|
</span>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
opacity: 0.8,
|
||||||
|
marginLeft: '4px'
|
||||||
|
}}>
|
||||||
|
{Math.round(confidence * 100)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PersonaEditorModal
|
||||||
|
isOpen={showEditor}
|
||||||
|
onClose={() => setShowEditor(false)}
|
||||||
|
personaData={personaData}
|
||||||
|
onSave={(data, saveToDatabase) => handleSavePersona(data, saveToDatabase)}
|
||||||
|
platform={platform}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonaChip;
|
||||||
@@ -0,0 +1,857 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface PersonaData {
|
||||||
|
// Core WritingPersona fields
|
||||||
|
id?: number;
|
||||||
|
user_id?: number;
|
||||||
|
persona_name: string;
|
||||||
|
archetype: string;
|
||||||
|
core_belief: string;
|
||||||
|
brand_voice_description: string;
|
||||||
|
linguistic_fingerprint: any;
|
||||||
|
platform_adaptations: any;
|
||||||
|
confidence_score: number;
|
||||||
|
ai_analysis_version: string;
|
||||||
|
|
||||||
|
// PlatformPersona fields
|
||||||
|
platform_type: string;
|
||||||
|
sentence_metrics: any;
|
||||||
|
lexical_features: any;
|
||||||
|
rhetorical_devices: any;
|
||||||
|
tonal_range: any;
|
||||||
|
stylistic_constraints: any;
|
||||||
|
content_format_rules: any;
|
||||||
|
engagement_patterns: any;
|
||||||
|
posting_frequency: any;
|
||||||
|
content_types: any;
|
||||||
|
platform_best_practices: any;
|
||||||
|
algorithm_considerations: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonaEditorModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
personaData: PersonaData | null;
|
||||||
|
onSave: (data: PersonaData, saveToDatabase: boolean) => void;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersonaEditorModal: React.FC<PersonaEditorModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
personaData,
|
||||||
|
onSave,
|
||||||
|
platform
|
||||||
|
}) => {
|
||||||
|
const [editedData, setEditedData] = useState<PersonaData | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'core' | 'linguistic' | 'platform' | 'optimization'>('core');
|
||||||
|
const [saveToDatabase, setSaveToDatabase] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (personaData) {
|
||||||
|
setEditedData({ ...personaData });
|
||||||
|
}
|
||||||
|
}, [personaData]);
|
||||||
|
|
||||||
|
if (!isOpen || !editedData) return null;
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(editedData, saveToDatabase);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (path: string, value: any) => {
|
||||||
|
setEditedData(prev => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const newData = { ...prev };
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current: any = newData;
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
if (!current[keys[i]]) {
|
||||||
|
current[keys[i]] = {};
|
||||||
|
}
|
||||||
|
current = current[keys[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[keys[keys.length - 1]] = value;
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldValue = (path: string, defaultValue: any = '') => {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let current: any = editedData;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (current && typeof current === 'object' && key in current) {
|
||||||
|
current = current[key];
|
||||||
|
} else {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current || defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'core', label: 'Core Identity', icon: '🎭' },
|
||||||
|
{ id: 'linguistic', label: 'Linguistic', icon: '📝' },
|
||||||
|
{ id: 'platform', label: 'Platform', icon: '🔗' },
|
||||||
|
{ id: 'optimization', label: 'Optimization', icon: '⚡' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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: 10000,
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '16px',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '800px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: '24px 24px 0 24px',
|
||||||
|
borderBottom: '1px solid #e5e7eb',
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
color: 'white'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700' }}>
|
||||||
|
Edit Persona: {getFieldValue('persona_name', 'Untitled Persona')}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '4px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
|
||||||
|
Platform: {platform} • Confidence: {(() => {
|
||||||
|
const score = getFieldValue('confidence_score', 0) || 0;
|
||||||
|
console.log('PersonaEditorModal confidence_score:', score);
|
||||||
|
return score;
|
||||||
|
})()}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '18px',
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
style={{
|
||||||
|
background: activeTab === tab.id ? 'rgba(255, 255, 255, 0.2)' : 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
padding: '12px 16px',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{tab.icon}</span>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '24px'
|
||||||
|
}}>
|
||||||
|
{activeTab === 'core' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Persona Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getFieldValue('persona_name', '')}
|
||||||
|
onChange={(e) => updateField('persona_name', e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#667eea'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Archetype / Guide
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('archetype', '')}
|
||||||
|
onChange={(e) => updateField('archetype', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '60px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#667eea'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Core Belief / Mission
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('core_belief', '')}
|
||||||
|
onChange={(e) => updateField('core_belief', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '80px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#667eea'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Brand Voice / Speaking Style
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('brand_voice_description', '')}
|
||||||
|
onChange={(e) => updateField('brand_voice_description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '80px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#667eea'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Confidence Score
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={getFieldValue('confidence_score', '')}
|
||||||
|
onChange={(e) => updateField('confidence_score', parseInt(e.target.value) || 0)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#667eea'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
AI Analysis Version
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getFieldValue('ai_analysis_version', '')}
|
||||||
|
onChange={(e) => updateField('ai_analysis_version', e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => e.target.style.borderColor = '#667eea'}
|
||||||
|
onBlur={(e) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'linguistic' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Sentence Metrics
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Average Sentence Length (words)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={getFieldValue('linguistic_fingerprint.sentence_metrics.average_sentence_length_words', '')}
|
||||||
|
onChange={(e) => updateField('linguistic_fingerprint.sentence_metrics.average_sentence_length_words', parseInt(e.target.value) || '')}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Preferred Sentence Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={getFieldValue('linguistic_fingerprint.sentence_metrics.preferred_sentence_type', '')}
|
||||||
|
onChange={(e) => updateField('linguistic_fingerprint.sentence_metrics.preferred_sentence_type', e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="simple_and_compound">Simple and Compound</option>
|
||||||
|
<option value="complex">Complex</option>
|
||||||
|
<option value="mixed">Mixed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Lexical Features
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Go-to Words (comma-separated)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('linguistic_fingerprint.lexical_features.go_to_words', []).join(', ')}
|
||||||
|
onChange={(e) => updateField('linguistic_fingerprint.lexical_features.go_to_words', e.target.value.split(',').map(w => w.trim()).filter(w => w))}
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '60px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
placeholder="e.g., innovative, strategic, transformative, impactful, leverage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Avoid Words (comma-separated)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('linguistic_fingerprint.lexical_features.avoid_words', []).join(', ')}
|
||||||
|
onChange={(e) => updateField('linguistic_fingerprint.lexical_features.avoid_words', e.target.value.split(',').map(w => w.trim()).filter(w => w))}
|
||||||
|
rows={2}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '60px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
placeholder="e.g., buzzwords, jargon, clichés, overly technical terms"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Tonal Range
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Default Tone
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={getFieldValue('tonal_range.default_tone', '')}
|
||||||
|
onChange={(e) => updateField('tonal_range.default_tone', e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="professional">Professional</option>
|
||||||
|
<option value="conversational">Conversational</option>
|
||||||
|
<option value="authoritative">Authoritative</option>
|
||||||
|
<option value="inspirational">Inspirational</option>
|
||||||
|
<option value="educational">Educational</option>
|
||||||
|
<option value="friendly">Friendly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Permissible Tones (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getFieldValue('tonal_range.permissible_tones', []).join(', ')}
|
||||||
|
onChange={(e) => updateField('tonal_range.permissible_tones', e.target.value.split(',').map(w => w.trim()).filter(w => w))}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
placeholder="e.g., professional, conversational, authoritative"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'platform' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Content Format Rules
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Character Limit
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={getFieldValue('content_format_rules.character_limit', '')}
|
||||||
|
onChange={(e) => updateField('content_format_rules.character_limit', parseInt(e.target.value) || 3000)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Optimal Length
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getFieldValue('content_format_rules.optimal_length', '')}
|
||||||
|
onChange={(e) => updateField('content_format_rules.optimal_length', e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
placeholder="e.g., 150-200 words"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Paragraph Structure
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('content_format_rules.paragraph_structure', '')}
|
||||||
|
onChange={(e) => updateField('content_format_rules.paragraph_structure', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '80px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Engagement Patterns
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Posting Frequency
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={getFieldValue('engagement_patterns.posting_frequency', '')}
|
||||||
|
onChange={(e) => updateField('engagement_patterns.posting_frequency', e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="2-3 times per week">2-3 times per week</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="bi-weekly">Bi-weekly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Best Posting Times
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getFieldValue('engagement_patterns.best_posting_times', '')}
|
||||||
|
onChange={(e) => updateField('engagement_patterns.best_posting_times', e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
placeholder="e.g., Tuesday-Thursday, 8-10 AM or 1-3 PM"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Content Types
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Preferred Content Types (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getFieldValue('content_types.preferred_types', []).join(', ')}
|
||||||
|
onChange={(e) => updateField('content_types.preferred_types', e.target.value.split(',').map(w => w.trim()).filter(w => w))}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
placeholder="e.g., thought leadership, industry insights, case studies, tips"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'optimization' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Platform Best Practices
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Hashtag Strategy
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('platform_best_practices.hashtag_strategy', '')}
|
||||||
|
onChange={(e) => updateField('platform_best_practices.hashtag_strategy', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '80px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Call-to-Action Style
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('platform_best_practices.cta_style', '')}
|
||||||
|
onChange={(e) => updateField('platform_best_practices.cta_style', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '80px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Algorithm Considerations
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Engagement Optimization
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('algorithm_considerations.engagement_optimization', '')}
|
||||||
|
onChange={(e) => updateField('algorithm_considerations.engagement_optimization', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '80px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Content Timing
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('algorithm_considerations.content_timing', '')}
|
||||||
|
onChange={(e) => updateField('algorithm_considerations.content_timing', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '80px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#374151' }}>
|
||||||
|
Stylistic Constraints
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', fontSize: '14px', fontWeight: '600', color: '#374151', marginBottom: '8px' }}>
|
||||||
|
Forbidden Elements
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={getFieldValue('stylistic_constraints.forbidden_elements', '')}
|
||||||
|
onChange={(e) => updateField('stylistic_constraints.forbidden_elements', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical',
|
||||||
|
minHeight: '80px',
|
||||||
|
transition: 'border-color 0.2s ease',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderTop: '1px solid #e5e7eb',
|
||||||
|
backgroundColor: '#f9fafb',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '14px', color: '#374151' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={saveToDatabase}
|
||||||
|
onChange={(e) => setSaveToDatabase(e.target.checked)}
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
/>
|
||||||
|
Save changes to database
|
||||||
|
<span style={{ fontSize: '12px', color: '#6b7280' }}>
|
||||||
|
{saveToDatabase ? 'Changes will be permanent' : 'Changes will be session-only'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: '2px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#d1d5db';
|
||||||
|
e.currentTarget.style.backgroundColor = '#f9fafb';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||||
|
e.currentTarget.style.backgroundColor = 'white';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
backgroundColor: '#667eea',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#5a67d8';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#667eea';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PersonaEditorModal;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { default as MainContentPreviewHeader } from './MainContentPreviewHeader';
|
||||||
|
export { default as ContentPreviewHeaderWithModals } from './ContentPreviewHeaderWithModals';
|
||||||
|
export { default as PersonaEditorModal } from './PersonaEditorModal';
|
||||||
|
export { default as PersonaChip } from './PersonaChip';
|
||||||
@@ -2,7 +2,6 @@ export { default as CitationHoverHandler } from './CitationHoverHandler';
|
|||||||
export { default as useTextSelectionHandler } from './TextSelectionHandler';
|
export { default as useTextSelectionHandler } from './TextSelectionHandler';
|
||||||
export { default as QuickEditToolbar } from './QuickEditToolbar';
|
export { default as QuickEditToolbar } from './QuickEditToolbar';
|
||||||
export { default as DiffPreviewModal } from './DiffPreviewModal';
|
export { default as DiffPreviewModal } from './DiffPreviewModal';
|
||||||
export { default as ContentPreviewHeader } from './ContentPreviewHeader';
|
export { MainContentPreviewHeader as ContentPreviewHeader, ContentPreviewHeaderWithModals } from './ContentPreviewHeaderComponents';
|
||||||
export { ContentPreviewHeaderWithModals } from './ContentPreviewHeader';
|
|
||||||
export { default as WritingAssistantCard } from './WritingAssistantCard';
|
export { default as WritingAssistantCard } from './WritingAssistantCard';
|
||||||
export { default as ContentDisplayArea } from './ContentDisplayArea';
|
export { default as ContentDisplayArea } from './ContentDisplayArea';
|
||||||
|
|||||||
Reference in New Issue
Block a user