ALwrity Facebook Writer CopilotKit Implementation Plan
This commit is contained in:
@@ -30,10 +30,27 @@ class StoryTone(str, Enum):
|
||||
|
||||
class StoryVisualOptions(BaseModel):
|
||||
"""Visual options for story."""
|
||||
background_type: str = Field(default="Solid color", description="Background type")
|
||||
# Background layer
|
||||
background_type: str = Field(default="Solid color", description="Background type (Solid color, Gradient, Image, Video)")
|
||||
background_image_prompt: Optional[str] = Field(None, description="If background_type is Image/Video, describe desired visual")
|
||||
gradient_style: Optional[str] = Field(None, description="Gradient style if gradient background is chosen")
|
||||
|
||||
# Text overlay styling
|
||||
text_overlay: bool = Field(default=True, description="Include text overlay")
|
||||
text_style: Optional[str] = Field(None, description="Headline/Subtext style, e.g., Bold, Minimal, Handwritten")
|
||||
text_color: Optional[str] = Field(None, description="Preferred text color or palette")
|
||||
text_position: Optional[str] = Field(None, description="Top/Center/Bottom; Left/Center/Right")
|
||||
|
||||
# Embellishments and interactivity
|
||||
stickers: bool = Field(default=True, description="Use stickers/emojis")
|
||||
interactive_elements: bool = Field(default=True, description="Include polls/questions")
|
||||
interactive_types: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="List of interactive types like ['poll','quiz','slider','countdown']"
|
||||
)
|
||||
|
||||
# CTA overlay
|
||||
call_to_action: Optional[str] = Field(None, description="Optional CTA copy to place on story")
|
||||
|
||||
|
||||
class FacebookStoryRequest(BaseModel):
|
||||
@@ -47,12 +64,20 @@ class FacebookStoryRequest(BaseModel):
|
||||
include: Optional[str] = Field(None, description="Elements to include in the story")
|
||||
avoid: Optional[str] = Field(None, description="Elements to avoid in the story")
|
||||
visual_options: StoryVisualOptions = Field(default_factory=StoryVisualOptions, description="Visual customization options")
|
||||
# Advanced text generation options (parity with original Streamlit module)
|
||||
use_hook: bool = Field(default=True, description="Start with a hook to grab attention")
|
||||
use_story: bool = Field(default=True, description="Use a short narrative arc")
|
||||
use_cta: bool = Field(default=True, description="Include a call to action")
|
||||
use_question: bool = Field(default=True, description="Ask a question to spur interaction")
|
||||
use_emoji: bool = Field(default=True, description="Use emojis where appropriate")
|
||||
use_hashtags: bool = Field(default=True, description="Include relevant hashtags in copy")
|
||||
|
||||
|
||||
class FacebookStoryResponse(BaseModel):
|
||||
"""Response model for Facebook story generation."""
|
||||
success: bool = Field(..., description="Whether the generation was successful")
|
||||
content: Optional[str] = Field(None, description="Generated story content")
|
||||
images_base64: Optional[List[str]] = Field(None, description="List of base64-encoded story images (PNG)")
|
||||
visual_suggestions: Optional[List[str]] = Field(None, description="Visual element suggestions")
|
||||
engagement_tips: Optional[List[str]] = Field(None, description="Engagement optimization tips")
|
||||
error: Optional[str] = Field(None, description="Error message if generation failed")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from ..models import *
|
||||
from ..models.carousel_models import CarouselSlide
|
||||
from .base_service import FacebookWriterBaseService
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
from typing import Dict, Any, List
|
||||
from ..models.story_models import FacebookStoryRequest, FacebookStoryResponse
|
||||
from .base_service import FacebookWriterBaseService
|
||||
try:
|
||||
from ...services.llm_providers.text_to_image_generation.gen_gemini_images import (
|
||||
generate_gemini_images_base64,
|
||||
)
|
||||
except Exception:
|
||||
generate_gemini_images_base64 = None # type: ignore
|
||||
|
||||
|
||||
class FacebookStoryService(FacebookWriterBaseService):
|
||||
@@ -38,10 +44,28 @@ class FacebookStoryService(FacebookWriterBaseService):
|
||||
# Generate visual suggestions and engagement tips
|
||||
visual_suggestions = self._generate_visual_suggestions(actual_story_type, request.visual_options)
|
||||
engagement_tips = self._generate_engagement_tips("story")
|
||||
# Optional: generate one story image (9:16) using Gemini
|
||||
images_base64: List[str] = []
|
||||
try:
|
||||
if generate_gemini_images_base64 is not None:
|
||||
img_prompt = request.visual_options.background_image_prompt or (
|
||||
f"Facebook story background for {request.business_type}. "
|
||||
f"Style: {actual_tone}. Type: {actual_story_type}. Vertical mobile 9:16, high contrast, legible overlay space."
|
||||
)
|
||||
images_base64 = generate_gemini_images_base64(
|
||||
img_prompt,
|
||||
enhance_prompt=False,
|
||||
aspect_ratio="9:16",
|
||||
max_retries=2,
|
||||
initial_retry_delay=1.0,
|
||||
) or []
|
||||
except Exception:
|
||||
images_base64 = []
|
||||
|
||||
return FacebookStoryResponse(
|
||||
success=True,
|
||||
content=content,
|
||||
images_base64=images_base64[:1],
|
||||
visual_suggestions=visual_suggestions,
|
||||
engagement_tips=engagement_tips,
|
||||
metadata={
|
||||
@@ -75,6 +99,28 @@ class FacebookStoryService(FacebookWriterBaseService):
|
||||
f"Create a {story_type} story"
|
||||
)
|
||||
|
||||
# Advanced writing flags
|
||||
advanced_lines = []
|
||||
if getattr(request, "use_hook", True):
|
||||
advanced_lines.append("- Start with a compelling hook in the first line")
|
||||
if getattr(request, "use_story", True):
|
||||
advanced_lines.append("- Use a mini narrative with a clear flow")
|
||||
if getattr(request, "use_cta", True):
|
||||
cta_text = request.visual_options.call_to_action or "Add a clear call-to-action"
|
||||
advanced_lines.append(f"- Include a CTA: {cta_text}")
|
||||
if getattr(request, "use_question", True):
|
||||
advanced_lines.append("- Ask a question to prompt replies or taps")
|
||||
if getattr(request, "use_emoji", True):
|
||||
advanced_lines.append("- Use a few relevant emojis for tone and scannability")
|
||||
if getattr(request, "use_hashtags", True):
|
||||
advanced_lines.append("- Include 1-3 relevant hashtags if appropriate")
|
||||
|
||||
advanced_str = "\n".join(advanced_lines)
|
||||
|
||||
# Visual details
|
||||
v = request.visual_options
|
||||
interactive_types_str = ", ".join(v.interactive_types) if v.interactive_types else "None specified"
|
||||
|
||||
prompt = f"""
|
||||
{base_prompt}
|
||||
|
||||
@@ -86,12 +132,20 @@ class FacebookStoryService(FacebookWriterBaseService):
|
||||
Content Requirements:
|
||||
- Include: {request.include or 'N/A'}
|
||||
- Avoid: {request.avoid or 'N/A'}
|
||||
{('\n' + advanced_str) if advanced_str else ''}
|
||||
|
||||
Visual Options:
|
||||
- Background Type: {request.visual_options.background_type}
|
||||
- Text Overlay: {request.visual_options.text_overlay}
|
||||
- Stickers/Emojis: {request.visual_options.stickers}
|
||||
- Interactive Elements: {request.visual_options.interactive_elements}
|
||||
- Background Type: {v.background_type}
|
||||
- Background Visual Prompt: {v.background_image_prompt or 'N/A'}
|
||||
- Gradient Style: {v.gradient_style or 'N/A'}
|
||||
- Text Overlay: {v.text_overlay}
|
||||
- Text Style: {v.text_style or 'N/A'}
|
||||
- Text Color: {v.text_color or 'N/A'}
|
||||
- Text Position: {v.text_position or 'N/A'}
|
||||
- Stickers/Emojis: {v.stickers}
|
||||
- Interactive Elements: {v.interactive_elements}
|
||||
- Interactive Types: {interactive_types_str}
|
||||
- Call To Action: {v.call_to_action or 'N/A'}
|
||||
|
||||
Please create a Facebook Story that:
|
||||
1. Is optimized for mobile viewing (vertical format)
|
||||
@@ -137,14 +191,28 @@ class FacebookStoryService(FacebookWriterBaseService):
|
||||
])
|
||||
|
||||
# Add general suggestions based on visual options
|
||||
if visual_options.text_overlay:
|
||||
if getattr(visual_options, "text_overlay", True):
|
||||
suggestions.append("Use bold, readable fonts for text overlays")
|
||||
|
||||
if visual_options.stickers:
|
||||
if getattr(visual_options, "text_style", None):
|
||||
suggestions.append(f"Match text style to tone: {visual_options.text_style}")
|
||||
if getattr(visual_options, "text_color", None):
|
||||
suggestions.append(f"Ensure sufficient contrast with text color: {visual_options.text_color}")
|
||||
if getattr(visual_options, "text_position", None):
|
||||
suggestions.append(f"Place text at {visual_options.text_position} to avoid occluding subject")
|
||||
|
||||
if getattr(visual_options, "stickers", True):
|
||||
suggestions.append("Add relevant emojis and stickers to increase engagement")
|
||||
|
||||
if visual_options.interactive_elements:
|
||||
if getattr(visual_options, "interactive_elements", True):
|
||||
suggestions.append("Include polls, questions, or swipe-up actions")
|
||||
if getattr(visual_options, "interactive_types", None):
|
||||
suggestions.append(f"Try interactive types: {', '.join(visual_options.interactive_types)}")
|
||||
|
||||
if getattr(visual_options, "background_type", None) in {"Image", "Video"} and getattr(visual_options, "background_image_prompt", None):
|
||||
suggestions.append("Source visuals based on background prompt for consistency")
|
||||
|
||||
if getattr(visual_options, "call_to_action", None):
|
||||
suggestions.append(f"Overlay CTA copy near focal point: {visual_options.call_to_action}")
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import datetime
|
||||
import streamlit as st
|
||||
import base64
|
||||
from typing import List, Optional, Tuple
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from loguru import logger
|
||||
from tenacity import retry, stop_after_attempt, wait_random_exponential
|
||||
import logging
|
||||
|
||||
# Import APIKeyManager
|
||||
from ...api_key_manager import APIKeyManager
|
||||
@@ -16,7 +16,9 @@ try:
|
||||
from google.generativeai import types
|
||||
except ImportError:
|
||||
genai = None
|
||||
logger.warning("Google genai library not available. Install with: pip install google-generativeai")
|
||||
logging.getLogger('gemini_image_generator').warning(
|
||||
"Google genai library not available. Install with: pip install google-generativeai"
|
||||
)
|
||||
|
||||
|
||||
from .save_image import save_generated_image
|
||||
@@ -28,9 +30,8 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger('gemini_image_generator')
|
||||
|
||||
# With image generation in Gemini, your imagination is the limit.
|
||||
# If what you see doesn't quite match what you had in mind, try adding more details to the prompt.
|
||||
# The more specific you are, the better Gemini can create images that reflect your vision.
|
||||
# With image generation in Gemini, your imagination is the limit.
|
||||
# Follow Google AI best practices for detailed prompts and iterative refinement.
|
||||
|
||||
# Generate images using Gemini
|
||||
# Gemini 2.0 Flash Experimental supports the ability to output text and inline images.
|
||||
@@ -167,161 +168,131 @@ class AIPromptGenerator:
|
||||
|
||||
return ", ".join(prompt_parts)
|
||||
|
||||
|
||||
def generate_gemini_image(prompt, keywords=None, style=None, focus=None, enhance_prompt=True, max_retries=3, initial_retry_delay=2, aspect_ratio="16:9"):
|
||||
"""
|
||||
Generate an image using Gemini's image generation capabilities.
|
||||
|
||||
Args:
|
||||
prompt (str): The text prompt for image generation
|
||||
keywords (list, optional): Keywords to enhance the prompt
|
||||
style (str, optional): Style of the image (photorealistic, artistic, etc.)
|
||||
focus (str, optional): Focus area for photorealistic images
|
||||
enhance_prompt (bool, optional): Whether to enhance the prompt with AI
|
||||
max_retries (int, optional): Maximum number of retry attempts
|
||||
initial_retry_delay (int, optional): Initial delay between retries
|
||||
aspect_ratio (str, optional): Aspect ratio for the generated image
|
||||
|
||||
Returns:
|
||||
str: The path to the generated image.
|
||||
"""
|
||||
logger.info(f"Generating image with prompt: '{prompt[:100]}...'")
|
||||
|
||||
# Use APIKeyManager instead of direct environment variable access
|
||||
def _ensure_client() -> Optional[object]:
|
||||
"""Create a Gemini client if available and API key is configured."""
|
||||
api_key_manager = APIKeyManager()
|
||||
api_key = api_key_manager.get_api_key("gemini")
|
||||
|
||||
if not api_key:
|
||||
error_msg = "Gemini API key not found. Please configure it in the onboarding process."
|
||||
logger.error(error_msg)
|
||||
st.error(f"🔑 {error_msg}")
|
||||
if not api_key or genai is None:
|
||||
return None
|
||||
|
||||
# Enhance the prompt if requested
|
||||
try:
|
||||
return genai.Client(api_key=api_key)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def generate_gemini_images_base64(
|
||||
prompt: str,
|
||||
*,
|
||||
keywords: Optional[list] = None,
|
||||
style: Optional[str] = None,
|
||||
focus: Optional[str] = None,
|
||||
enhance_prompt: bool = True,
|
||||
aspect_ratio: str = "9:16",
|
||||
max_retries: int = 2,
|
||||
initial_retry_delay: float = 1.0,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Return list of base64 PNG images generated from a prompt.
|
||||
|
||||
Implements best practices per Gemini docs: send text prompt, parse inline image parts,
|
||||
and return base64 data suitable for API responses. No Streamlit, no printing.
|
||||
|
||||
Docs: https://ai.google.dev/gemini-api/docs/image-generation
|
||||
"""
|
||||
logger = logging.getLogger('gemini_image_generator')
|
||||
logger.info("Generating image (base64) with Gemini")
|
||||
|
||||
if enhance_prompt and keywords:
|
||||
prompt_generator = AIPromptGenerator()
|
||||
if style == "photorealistic" and focus:
|
||||
logger.info(f"Generating photorealistic prompt with focus: {focus}")
|
||||
enhanced_prompt = prompt_generator.generate_photorealistic_prompt(keywords, focus)
|
||||
else:
|
||||
logger.info("Generating enhanced prompt")
|
||||
enhanced_prompt = prompt_generator.generate_prompt(keywords)
|
||||
|
||||
# Combine the enhanced prompt with the original prompt
|
||||
prompt = f"{prompt}\n\nEnhanced prompt: {enhanced_prompt}"
|
||||
logger.info(f"Final prompt: '{prompt[:100]}...'")
|
||||
|
||||
# Add aspect ratio to the prompt
|
||||
pg = AIPromptGenerator()
|
||||
enhanced = (
|
||||
pg.generate_photorealistic_prompt(keywords, focus)
|
||||
if style == "photorealistic" and focus
|
||||
else pg.generate_prompt(keywords)
|
||||
)
|
||||
prompt = f"{prompt}\n\nEnhanced prompt: {enhanced}"
|
||||
|
||||
# Optional hint in-text for aspect ratio; API doesn't take ratio param directly
|
||||
if aspect_ratio:
|
||||
prompt += f"\n\nPlease generate the image with {aspect_ratio} aspect ratio."
|
||||
|
||||
retry_count = 0
|
||||
retry_delay = initial_retry_delay
|
||||
|
||||
while retry_count <= max_retries:
|
||||
prompt = f"{prompt}\n\nAspect ratio: {aspect_ratio}"
|
||||
|
||||
client = _ensure_client()
|
||||
if client is None:
|
||||
logger.warning("Gemini client not available or API key missing")
|
||||
return []
|
||||
|
||||
retry = 0
|
||||
delay = initial_retry_delay
|
||||
while retry <= max_retries:
|
||||
try:
|
||||
client = genai.Client(api_key=api_key)
|
||||
contents = (prompt)
|
||||
|
||||
logger.info("Sending request to Gemini API")
|
||||
response = client.models.generate_content(
|
||||
model="gemini-2.0-flash-exp-image-generation",
|
||||
contents=contents,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=['Text', 'Image']
|
||||
)
|
||||
model="gemini-2.5-flash-image-preview",
|
||||
contents=[prompt],
|
||||
)
|
||||
logger.info("Received response from Gemini API")
|
||||
|
||||
img_name = None
|
||||
images_b64: List[str] = []
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.text is not None:
|
||||
logger.info(f"Received text response: '{part.text[:100]}...'")
|
||||
print(part.text)
|
||||
elif part.inline_data is not None:
|
||||
logger.info("Received image data from Gemini")
|
||||
image = Image.open(BytesIO((part.inline_data.data)))
|
||||
|
||||
# Resize image to match aspect ratio if needed
|
||||
if aspect_ratio:
|
||||
current_width, current_height = image.size
|
||||
target_width = current_width
|
||||
target_height = current_height
|
||||
|
||||
# Calculate target dimensions based on aspect ratio
|
||||
if aspect_ratio == "16:9":
|
||||
target_height = int(current_width * 9/16)
|
||||
elif aspect_ratio == "9:16":
|
||||
target_width = int(current_height * 9/16)
|
||||
elif aspect_ratio == "4:3":
|
||||
target_height = int(current_width * 3/4)
|
||||
elif aspect_ratio == "3:4":
|
||||
target_width = int(current_height * 3/4)
|
||||
elif aspect_ratio == "1:1":
|
||||
target_size = min(current_width, current_height)
|
||||
target_width = target_size
|
||||
target_height = target_size
|
||||
|
||||
logger.info(f"Resizing image from {current_width}x{current_height} to {target_width}x{target_height}")
|
||||
|
||||
# Create a new image with the target dimensions
|
||||
resized_image = Image.new('RGB', (target_width, target_height), (255, 255, 255))
|
||||
|
||||
# Calculate position to paste the original image
|
||||
paste_x = (target_width - current_width) // 2
|
||||
paste_y = (target_height - current_height) // 2
|
||||
|
||||
# Paste the original image onto the new canvas
|
||||
resized_image.paste(image, (paste_x, paste_y))
|
||||
image = resized_image
|
||||
|
||||
if part.text is not None:
|
||||
img_name = f'{part.text}-gemini-native-image.png'
|
||||
if getattr(part, 'inline_data', None) is not None:
|
||||
# part.inline_data.data is bytes (base64 decoded by SDK?)
|
||||
# Standardize to base64 string for API consumers
|
||||
raw = part.inline_data.data
|
||||
if isinstance(raw, bytes):
|
||||
images_b64.append(base64.b64encode(raw).decode('utf-8'))
|
||||
else:
|
||||
img_name = f'gemini-native-image-{datetime.datetime.now().strftime("%Y%m%d-%H%M%S")}.png'
|
||||
try:
|
||||
logger.info(f"Saving image to: {img_name}")
|
||||
image.save(img_name)
|
||||
|
||||
# Create a dictionary with the expected format for save_generated_image
|
||||
img_response = {
|
||||
"artifacts": [
|
||||
{
|
||||
"base64": base64.b64encode(open(img_name, "rb").read()).decode('utf-8')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Call save_generated_image with the correct format
|
||||
save_generated_image(img_response)
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to save image: {err}")
|
||||
st.error(f"Failed to save image: {err}")
|
||||
|
||||
logger.info(f"Image generation completed. Image name: {img_name}")
|
||||
return img_name
|
||||
except Exception as err:
|
||||
error_message = str(err)
|
||||
logger.error(f"Error in generate_gemini_image: {err}")
|
||||
|
||||
# Check if this is a 503 UNAVAILABLE error
|
||||
if "503 UNAVAILABLE" in error_message and retry_count < max_retries:
|
||||
retry_count += 1
|
||||
logger.info(f"Model is overloaded. Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})")
|
||||
st.warning(f"The image generation service is currently busy. Retrying in {retry_delay} seconds...")
|
||||
time.sleep(retry_delay)
|
||||
# Exponential backoff
|
||||
retry_delay *= 2
|
||||
else:
|
||||
st.error(f"Error generating image: {err}")
|
||||
return None
|
||||
|
||||
# If we've exhausted all retries
|
||||
st.error("The image generation service is currently unavailable. Please try again later.")
|
||||
return None
|
||||
# Some SDKs may already present base64 str
|
||||
images_b64.append(str(raw))
|
||||
return images_b64
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
logger.warning(f"Gemini image gen error: {msg}")
|
||||
if "503" in msg and retry < max_retries:
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
retry += 1
|
||||
continue
|
||||
return []
|
||||
|
||||
|
||||
def edit_image(image_path, prompt, max_retries=3, initial_retry_delay=2):
|
||||
def generate_gemini_image(
|
||||
prompt,
|
||||
keywords=None,
|
||||
style=None,
|
||||
focus=None,
|
||||
enhance_prompt=True,
|
||||
max_retries=2,
|
||||
initial_retry_delay=1.0,
|
||||
aspect_ratio="9:16",
|
||||
):
|
||||
"""
|
||||
Backward-compatible wrapper that generates a single image file on disk and returns path.
|
||||
Prefer generate_gemini_images_base64 in new code paths.
|
||||
"""
|
||||
logger = logging.getLogger('gemini_image_generator')
|
||||
images = generate_gemini_images_base64(
|
||||
prompt,
|
||||
keywords=keywords,
|
||||
style=style,
|
||||
focus=focus,
|
||||
enhance_prompt=enhance_prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
max_retries=max_retries,
|
||||
initial_retry_delay=initial_retry_delay,
|
||||
)
|
||||
if not images:
|
||||
return None
|
||||
# Persist first image to file for legacy callers
|
||||
img_b64 = images[0]
|
||||
img_bytes = base64.b64decode(img_b64)
|
||||
img = Image.open(BytesIO(img_bytes))
|
||||
out_name = f'gemini-native-image-{datetime.datetime.now().strftime("%Y%m%d-%H%M%S")}.png'
|
||||
try:
|
||||
img.save(out_name)
|
||||
# Also call save_generated_image to reuse existing pipeline
|
||||
save_generated_image({"artifacts": [{"base64": img_b64}]})
|
||||
return out_name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def edit_image(image_path, prompt, max_retries=2, initial_retry_delay=1.0):
|
||||
"""
|
||||
- Image editing (text and image to image)
|
||||
Example prompt: "Edit this image to make it look like a cartoon"
|
||||
@@ -352,7 +323,9 @@ def edit_image(image_path, prompt, max_retries=3, initial_retry_delay=2):
|
||||
|
||||
while retry_count <= max_retries:
|
||||
try:
|
||||
client = genai.Client()
|
||||
client = _ensure_client()
|
||||
if client is None:
|
||||
return None
|
||||
text_input = (prompt)
|
||||
|
||||
logger.info("Sending request to Gemini API for image editing")
|
||||
@@ -367,13 +340,9 @@ def edit_image(image_path, prompt, max_retries=3, initial_retry_delay=2):
|
||||
|
||||
edited_img_name = None
|
||||
for part in response.candidates[0].content.parts:
|
||||
if part.text is not None:
|
||||
logger.info(f"Received text response: '{part.text[:100]}...'")
|
||||
st.write(part.text)
|
||||
elif part.inline_data is not None:
|
||||
if getattr(part, 'inline_data', None) is not None:
|
||||
logger.info("Received edited image data from Gemini")
|
||||
edited_image = Image.open(BytesIO(part.inline_data.data))
|
||||
edited_image.show()
|
||||
|
||||
# Save the edited image
|
||||
edited_img_name = f'edited-{os.path.basename(image_path)}'
|
||||
@@ -394,28 +363,22 @@ def edit_image(image_path, prompt, max_retries=3, initial_retry_delay=2):
|
||||
save_generated_image(img_response)
|
||||
except Exception as err:
|
||||
logger.error(f"Failed to save edited image: {err}")
|
||||
st.error(f"Failed to save edited image: {err}")
|
||||
|
||||
logger.info(f"Image editing completed. Edited image name: {edited_img_name}")
|
||||
return edited_img_name
|
||||
except Exception as err:
|
||||
error_message = str(err)
|
||||
logger.error(f"Error in edit_image: {err}")
|
||||
|
||||
# Check if this is a 503 UNAVAILABLE error
|
||||
if "503 UNAVAILABLE" in error_message and retry_count < max_retries:
|
||||
# Retry on transient 503
|
||||
if "503" in error_message and retry_count < max_retries:
|
||||
retry_count += 1
|
||||
logger.info(f"Model is overloaded. Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})")
|
||||
st.warning(f"The image editing service is currently busy. Retrying in {retry_delay} seconds...")
|
||||
logger.info(f"Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})")
|
||||
time.sleep(retry_delay)
|
||||
# Exponential backoff
|
||||
retry_delay *= 2
|
||||
else:
|
||||
st.error(f"Error editing image: {err}")
|
||||
return None
|
||||
|
||||
# If we've exhausted all retries
|
||||
st.error("The image editing service is currently unavailable. Please try again later.")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
215
docs/Facebook_Writer_CopilotKit_Integration_Plan.md
Normal file
215
docs/Facebook_Writer_CopilotKit_Integration_Plan.md
Normal file
@@ -0,0 +1,215 @@
|
||||
|
||||
# Facebook Writer + CopilotKit: Feature Set and Implementation Plan
|
||||
|
||||
## 0) Current Implementation Status (Updated)
|
||||
- Core page and routing: `/facebook-writer` implemented with `CopilotSidebar` and scoped styling.
|
||||
- Readables: `postDraft`, `notes` exposed to Copilot; preferences summarized into system message.
|
||||
- Predictive state updates: live typing with progressive diff preview (green adds, red strikethrough deletes), then auto-commit.
|
||||
- Edit actions: `editFacebookDraft` (Casual, Professional, Upbeat, Shorten, Lengthen, TightenHook, AddCTA) with HITL micro-form; applies live preview via custom events.
|
||||
- Generation actions: `generateFacebookPost`, `generateFacebookHashtags`, `generateFacebookAdCopy` integrated with FastAPI endpoints; results synced to editor via window events.
|
||||
- Facebook Story: `generateFacebookStory` added with advanced and visual options (tone, include/avoid, CTA, stickers, text overlay, interactive types, etc.). Backend returns `content` plus one 9:16 image (`images_base64[0]`) generated via Gemini and the UI renders a Story Images panel.
|
||||
- Image generation module refactor: `gen_gemini_images.py` made backend-safe (removed Streamlit), added base64-first API, light retries, aligned with Gemini best practices.
|
||||
- Input robustness: frontend normalization/mapping to backend enum strings (prevents 422); friendly HITL validation.
|
||||
- Suggestions: progressive suggestions switch from “create” to “edit” when draft exists; stage-aware heuristics in place.
|
||||
- Chat memory and preferences: localStorage persistence of last 50 messages; recent conversation and saved preferences injected into `makeSystemMessage`; “Clear chat memory” button.
|
||||
- Confirm/Reject: explicit controls for predictive edits (Confirm changes / Discard) implemented.
|
||||
- Observability: Facebook writer requests flow through existing middleware; compact header control already live app-wide. Route-specific counters verification pending (planned below).
|
||||
|
||||
Gaps / Remaining:
|
||||
- Context-aware suggestions need further refinement (e.g., based on draft length, tone, goal, time of day).
|
||||
- Tests for actions/handlers, reducer-like state transitions, and suggestion sets.
|
||||
- Observability counters and tags for `/api/facebook-writer/*` endpoints.
|
||||
- Backend session persistence (server-side conversation memory) for cross-device continuity (optional, phase-able).
|
||||
- Image generation controls (toggle, retries, error UX), caching, and cost guardrails.
|
||||
|
||||
|
||||
## 1) Goals
|
||||
- Provide a specialized Facebook Writer surface powered by CopilotKit.
|
||||
- Deliver intelligent, HITL (human-in-the-loop) workflows using Facebook Writer PR endpoints.
|
||||
- Reuse CopilotKit best practices (predictive state updates) as demonstrated in the example demo.
|
||||
- Ensure observability via existing middleware so system status appears in the main header control.
|
||||
|
||||
Reference demo: https://demo-viewer-five.vercel.app/feature/predictive_state_updates
|
||||
|
||||
---
|
||||
|
||||
## 2) Feature Set
|
||||
|
||||
### A. Core Copilot sidebar (Facebook Writer page)
|
||||
- Personalized title and greeting (brand/tenant aware when available).
|
||||
- Progressive suggestion groups:
|
||||
- Social content
|
||||
- Ads & campaigns
|
||||
- Engagement & optimization
|
||||
- Always-on context-aware quick actions based on draft state (empty vs non-empty vs long draft).
|
||||
|
||||
### B. Predictive state + collaborative editing
|
||||
- Readables
|
||||
- draft: current post text
|
||||
- notes/context: campaign intent, audience, key points
|
||||
- preferences: tone, objective, hashtags on/off (persisted locally; summarized to system message)
|
||||
- Actions
|
||||
- updateFacebookPostDraft(content)
|
||||
- appendToFacebookPostDraft(content)
|
||||
- editFacebookDraft(operation)
|
||||
- summarizeDraft() (planned)
|
||||
- rewriteDraft(style|objective) (planned)
|
||||
|
||||
### C. PR endpoint coverage (initial, minimal)
|
||||
- POST /api/facebook-writer/post/generate (implemented)
|
||||
- POST /api/facebook-writer/hashtags/generate (implemented)
|
||||
- POST /api/facebook-writer/ad-copy/generate (implemented)
|
||||
- POST /api/facebook-writer/story/generate (implemented)
|
||||
- GET /api/facebook-writer/tools (implemented)
|
||||
- GET /api/facebook-writer/health (implemented)
|
||||
|
||||
Next endpoints (planned):
|
||||
- Subsequent additions: reel/carousel/event/group/page-about
|
||||
|
||||
### D. HITL micro-forms
|
||||
- Minimal modals inline in chat for:
|
||||
- Objective (awareness, engagement, traffic, launch)
|
||||
- Tone (professional, casual, upbeat, custom)
|
||||
- Audience (free text)
|
||||
- Include/avoid (free text)
|
||||
- Hashtags on/off
|
||||
|
||||
### E. Intelligent suggestions
|
||||
- Empty draft → “Create launch teaser”, “Benefit-first post”, “3 variants to A/B test”
|
||||
- Non-empty draft → “Tighten hook”, “Add CTA”, “Rewrite for professional tone”, “Generate hashtags” (live)
|
||||
- Long draft → “Summarize to 120-150 chars intro”, “Split into carousel captions” (future)
|
||||
|
||||
### F. Observability and status
|
||||
- Ensure facebook endpoints counted in monitoring so the compact header “System • STATUS” reflects their activity.
|
||||
|
||||
---
|
||||
|
||||
## 3) Frontend Implementation Plan
|
||||
|
||||
### 3.1 Route and page
|
||||
- Route: `/facebook-writer`
|
||||
- Component: `frontend/src/components/FacebookWriter/FacebookWriter.tsx`
|
||||
- CopilotSidebar (scoped styling class)
|
||||
- Textareas for notes and postDraft
|
||||
- Readables: notes, postDraft
|
||||
- Actions: updateFacebookPostDraft, appendToFacebookPostDraft
|
||||
|
||||
### 3.2 API client
|
||||
- File: `frontend/src/services/facebookWriterApi.ts`
|
||||
- postGenerate(req)
|
||||
- adCopyGenerate(req)
|
||||
- hashtagsGenerate(req)
|
||||
- storyGenerate(req) [advanced + visual options]
|
||||
- tools(), health()
|
||||
- Types aligned with PR models (enum value strings must match server models).
|
||||
|
||||
### 3.3 Copilot actions (HITL + server calls)
|
||||
- File: `frontend/src/components/FacebookWriter/RegisterFacebookActions.tsx`
|
||||
- Action: generateFacebookPost
|
||||
- renderAndWaitForResponse → prompt for goal, tone, audience, include/avoid, hashtags
|
||||
- Call api.postGenerate → update draft
|
||||
- Action: generateHashtags
|
||||
- renderAndWaitForResponse → topic or use draft
|
||||
- Call api.hashtagsGenerate → append to draft
|
||||
- Action: generateAdCopy (implemented)
|
||||
- renderAndWaitForResponse → prompt for business_type, product/service, objective, format, audience, targeting basics, USP, budget
|
||||
- Call api.adCopyGenerate → append primary text to draft; keep variations for UI
|
||||
- Action: generateFacebookStory (implemented)
|
||||
- renderAndWaitForResponse → advanced (hooks, CTA, etc.) and visual options (background type/prompt, overlay, interactive types)
|
||||
- Call api.storyGenerate → append story content; dispatch `fbwriter:storyImages` to render returned image(s)
|
||||
- Helper: custom window events keep editor as single source of truth.
|
||||
|
||||
### 3.4 Suggestions and system message
|
||||
- Suggestions computed from draft length, last action result, and notes presence.
|
||||
- System message includes short brand tone guidance when available.
|
||||
|
||||
### 3.5 Demo parity (predictive state updates)
|
||||
- Expose two local actions for state updates:
|
||||
- updateFacebookPostDraft
|
||||
- appendToFacebookPostDraft
|
||||
- Ensure Copilot can call those without round-tripping to backend for quick edits.
|
||||
- Confirm/Reject step before committing predictive edits (implemented)
|
||||
|
||||
---
|
||||
|
||||
## 4) Backend Integration Plan
|
||||
|
||||
### 4.1 Use PR structure
|
||||
- Routers: `backend/api/facebook_writer/routers/facebook_router.py`.
|
||||
- Services: `backend/api/facebook_writer/services/*`.
|
||||
- Models: `backend/api/facebook_writer/models/*`.
|
||||
|
||||
### 4.2 Minimal requests for post.generate
|
||||
- Map HITL selections to `FacebookPostRequest` fields:
|
||||
- post_goal: enum string value (e.g., “Build brand awareness”)
|
||||
- post_tone: enum string value (e.g., “Professional”)
|
||||
- media_type: “None” (default)
|
||||
- advanced_options: from toggles
|
||||
- Handle 422 by ensuring exact enum text.
|
||||
|
||||
### 4.3 Monitoring
|
||||
- No changes required if middleware already counts routes; confirm they appear in status.
|
||||
|
||||
---
|
||||
|
||||
## 5) UX details
|
||||
- Sidebar personalized title: “ALwrity • Facebook Writer”.
|
||||
- Glassomorphic style aligned with SEO assistant.
|
||||
- Accessibility: focus-visible rings, reduced-motion respect.
|
||||
- Error paths: concise toast + retry in HITL form.
|
||||
|
||||
---
|
||||
|
||||
## 6) Milestones
|
||||
- M1 (Done): Page + readables + predictive edits + suggestions (start/edit) + health/tools probe.
|
||||
- M2 (Done): HITL for post.generate; integrate API; hashtags action; editor sync.
|
||||
- M3 (Updated): Ad copy (done), Variations UI (done), Story (done), context-aware suggestions (ongoing), tests (pending).
|
||||
- M4 (Planned): Reel/Carousel; variants pipeline; scheduling hooks; session persistence (optional).
|
||||
|
||||
### 6.1 Next-phase Tasks (Detailed)
|
||||
- Ad Copy (M3)
|
||||
- Suggestion chips: “Create ad copy”, “Short ad variant (primary text)”, “Insert headline X”.
|
||||
- A/B insert UX: quick insert/replace buttons already present; add multi-insert queue.
|
||||
- Story (M3)
|
||||
- HITL toggle for image generation on/off; regenerate button; image count (1–3) cap.
|
||||
- Gallery UX: copy/download, insert image markdown into draft, or upload to asset store.
|
||||
- Improve visual prompt composition from form fields (brand + tone + CTA region).
|
||||
- Context-aware Suggestions (M3)
|
||||
- Derive stage features: draft length buckets, tone inferred from text, presence of CTA/hashtags.
|
||||
- Swap suggestion sets accordingly; include “Summarize intro” for long drafts.
|
||||
- Confirm/Reject for Predictive Edits (M3)
|
||||
- Option: preference to auto-confirm future edits.
|
||||
- Tests (M3)
|
||||
- Unit test action handlers (param mapping, event dispatch), reducer-like state transitions.
|
||||
- Snapshot test suggestion sets for start/edit/long-draft.
|
||||
- API client smoke tests for post/hashtags/ad-copy/story.
|
||||
- Observability (M3)
|
||||
- Verify `/api/facebook-writer/*` counters in header; add tags for route family.
|
||||
- Log action success/error counts.
|
||||
- Session Persistence (M4, optional)
|
||||
- Backend `copilot_sessions` + `messages` tables; persist assistant/user messages.
|
||||
- Provide `sessionId` per user/page; prehydrate sidebar from server.
|
||||
- Next endpoints (M4)
|
||||
- Implement reel/carousel/event/group/page-about endpoints with parity HITL forms.
|
||||
|
||||
### 6.2 Known limitations / Non-goals (for now)
|
||||
- Image generation: Gemini outputs include SynthID watermark; outputs not guaranteed each call; currently generates 1 image for story.
|
||||
- Cost/quotas: No server-side budgeting/limits yet for image gen; add per-user caps and caching.
|
||||
- Asset pipeline: No upload/CDN integration yet; images are rendered inline as base64.
|
||||
|
||||
---
|
||||
|
||||
## 7) Risks & Mitigations
|
||||
- Enum mismatches → Use exact server enum strings; surface helpful errors.
|
||||
- Long outputs → Clamp `max_tokens` server-side; provide “shorten” action client-side.
|
||||
- Rate limiting → Respect retry/backoff; keep client timeouts reasonable.
|
||||
|
||||
Reference (Gemini image generation best practices): https://ai.google.dev/gemini-api/docs/image-generation
|
||||
|
||||
---
|
||||
|
||||
## 8) Success Criteria
|
||||
- End-to-end draft creation via Copilot with a single click (HITL).
|
||||
- Predictive state edits observable in real-time.
|
||||
- Monitoring reflects API usage in the header control.
|
||||
- Clean, reproducible flows for post + hashtags; extendable to ads and other tools.
|
||||
@@ -1,14 +1,209 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, Typography, TextField, Paper } from '@mui/material';
|
||||
import { Box, Container, Typography, TextField, Paper, Button } from '@mui/material';
|
||||
import { CopilotSidebar } from '@copilotkit/react-ui';
|
||||
import { useCopilotReadable, useCopilotAction } from '@copilotkit/react-core';
|
||||
import '@copilotkit/react-ui/styles.css';
|
||||
import RegisterFacebookActions from './RegisterFacebookActions';
|
||||
import RegisterFacebookEditActions from './RegisterFacebookEditActions';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// --- Simple localStorage-backed chat memory ---
|
||||
const HISTORY_KEY = 'fbwriter:chatHistory';
|
||||
const PREFS_KEY = 'fbwriter:preferences';
|
||||
|
||||
type ChatMsg = { role: 'user' | 'assistant'; content: string; ts: number };
|
||||
|
||||
function loadHistory(): ChatMsg[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw);
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr.filter((m: any) => m && typeof m.content === 'string' && (m.role === 'user' || m.role === 'assistant'));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function saveHistory(msgs: ChatMsg[]) {
|
||||
try { localStorage.setItem(HISTORY_KEY, JSON.stringify(msgs.slice(-50))); } catch {}
|
||||
}
|
||||
|
||||
function pushHistory(role: 'user' | 'assistant', content: string) {
|
||||
const msgs = loadHistory();
|
||||
msgs.push({ role, content: String(content || '').slice(0, 4000), ts: Date.now() });
|
||||
saveHistory(msgs);
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
try { localStorage.removeItem(HISTORY_KEY); } catch {}
|
||||
}
|
||||
|
||||
function getPreferences(): Record<string, any> {
|
||||
try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {}; } catch { return {}; }
|
||||
}
|
||||
|
||||
function summarizeHistory(maxChars = 1000): string {
|
||||
const msgs = loadHistory();
|
||||
if (!msgs.length) return '';
|
||||
const recent = msgs.slice(-10).map(m => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`);
|
||||
const joined = recent.join('\n');
|
||||
return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined;
|
||||
}
|
||||
|
||||
function computeEditedText(op: string, src: string): string {
|
||||
const opL = (op || '').toLowerCase();
|
||||
if (opL === 'shorten') return src.length > 240 ? src.slice(0, 220) + '…' : src;
|
||||
if (opL === 'lengthen') return src + '\n\nLearn more at our page!';
|
||||
if (opL === 'tightenhook') {
|
||||
const lines = src.split('\n');
|
||||
if (lines.length) lines[0] = '🔥 ' + lines[0].replace(/^\W+/, '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
if (opL === 'addcta') return src + '\n\n👉 Tell us your thoughts in the comments!';
|
||||
if (opL === 'casual') return src.replace(/\b(you will|you should)\b/gi, "you'll").replace(/\bdo not\b/gi, "don't");
|
||||
if (opL === 'professional') return src.replace(/\bcan't\b/gi, 'cannot').replace(/\bwon't\b/gi, 'will not');
|
||||
if (opL === 'upbeat') return src + ' 🎉';
|
||||
return src;
|
||||
}
|
||||
|
||||
function diffMarkup(oldText: string, newText: string): string {
|
||||
const MAX = 4000;
|
||||
const a = (oldText || '').slice(0, MAX);
|
||||
const b = (newText || '').slice(0, MAX);
|
||||
const n = a.length, m = b.length;
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
if (a[i] === b[j]) dp[i][j] = dp[i + 1][j + 1] + 1;
|
||||
else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
let i = 0, j = 0;
|
||||
let out = '';
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
out += a[i];
|
||||
i++; j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
out += `<s class=\"fbw-del\">${escapeHtml(a[i])}</s>`;
|
||||
i++;
|
||||
} else {
|
||||
out += `<em class=\"fbw-add\">${escapeHtml(b[j])}</em>`;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) { out += `<s class=\"fbw-del\">${escapeHtml(a[i++])}</s>`; }
|
||||
while (j < m) { out += `<em class=\"fbw-add\">${escapeHtml(b[j++])}</em>`; }
|
||||
if (oldText.length > MAX || newText.length > MAX) out += '<span class=\"fbw-more\"> …</span>';
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function simpleMarkdownToHtml(markdown: string): string {
|
||||
// Very small, safe-ish markdown renderer for bold, italics, lists, headings, paragraphs
|
||||
// 1) Escape HTML first
|
||||
let html = escapeHtml(markdown || '');
|
||||
// 2) Headings (##, # at line start)
|
||||
html = html.replace(/^###\s+(.*)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^##\s+(.*)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^#\s+(.*)$/gm, '<h1>$1</h1>');
|
||||
// 3) Bold and italics
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
// 4) Lists: lines starting with * or -
|
||||
html = html.replace(/^(?:\*|-)\s+(.+)$/gm, '<li>$1</li>');
|
||||
// Wrap consecutive <li> into <ul>
|
||||
html = html.replace(/(<li>.*<\/li>)(\s*(<li>.*<\/li>))+/gms, (m) => `<ul>${m}</ul>`);
|
||||
// 5) Line breaks → paragraphs
|
||||
html = html.replace(/^(?!<h\d>|<ul>|<li>)(.+)$/gm, '<p>$1</p>');
|
||||
// Remove paragraphs around list items
|
||||
html = html.replace(/<p>(<li>.*?<\/li>)<\/p>/gms, '$1');
|
||||
html = html.replace(/<p>(<ul>.*?<\/ul>)<\/p>/gms, '$1');
|
||||
return html;
|
||||
}
|
||||
|
||||
const FacebookWriter: React.FC = () => {
|
||||
const [postDraft, setPostDraft] = React.useState<string>('');
|
||||
const [notes, setNotes] = React.useState<string>('');
|
||||
const [stage, setStage] = React.useState<'start' | 'edit'>('start');
|
||||
const [livePreviewHtml, setLivePreviewHtml] = React.useState<string>('');
|
||||
const [isPreviewing, setIsPreviewing] = React.useState<boolean>(false);
|
||||
const [pendingEdit, setPendingEdit] = React.useState<{ src: string; target: string } | null>(null);
|
||||
const [historyVersion, setHistoryVersion] = React.useState<number>(0);
|
||||
const [adVariations, setAdVariations] = React.useState<{
|
||||
headline_variations: string[];
|
||||
primary_text_variations: string[];
|
||||
description_variations: string[];
|
||||
cta_variations: string[];
|
||||
} | null>(null);
|
||||
const [storyImages, setStoryImages] = React.useState<string[] | null>(null);
|
||||
const renderRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [selectionMenu, setSelectionMenu] = React.useState<{ x: number; y: number; text: string } | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onUpdate = (e: any) => {
|
||||
setPostDraft(String(e.detail || ''));
|
||||
setStage('edit');
|
||||
};
|
||||
const onAppend = (e: any) => {
|
||||
setPostDraft(prev => (prev || '') + String(e.detail || ''));
|
||||
setStage('edit');
|
||||
};
|
||||
const onAssistantMessage = (e: any) => {
|
||||
const content = e?.detail?.content ?? e?.detail ?? '';
|
||||
if (content) {
|
||||
pushHistory('assistant', String(content));
|
||||
setHistoryVersion(v => v + 1);
|
||||
}
|
||||
};
|
||||
const onApplyEdit = (e: any) => {
|
||||
const op = (e?.detail?.operation || '').toLowerCase();
|
||||
const src = postDraft || '';
|
||||
const target = computeEditedText(op, src);
|
||||
setIsPreviewing(true);
|
||||
setStage('edit');
|
||||
setPendingEdit({ src, target });
|
||||
let idx = 0;
|
||||
const total = target.length;
|
||||
const intervalMs = 20;
|
||||
const step = Math.max(1, Math.floor(total / 120)); // ~2 seconds
|
||||
const interval = setInterval(() => {
|
||||
idx += step;
|
||||
if (idx >= total) idx = total;
|
||||
const partial = target.slice(0, idx);
|
||||
setLivePreviewHtml(diffMarkup(src, partial));
|
||||
if (idx === total) {
|
||||
clearInterval(interval);
|
||||
// Keep preview open and wait for user to confirm or discard.
|
||||
}
|
||||
}, intervalMs);
|
||||
};
|
||||
window.addEventListener('fbwriter:updateDraft', onUpdate as any);
|
||||
window.addEventListener('fbwriter:appendDraft', onAppend as any);
|
||||
window.addEventListener('fbwriter:assistantMessage', onAssistantMessage as any);
|
||||
const onAdVariations = (e: any) => {
|
||||
const v = e?.detail;
|
||||
if (v) setAdVariations(v);
|
||||
};
|
||||
const onStoryImages = (e: any) => {
|
||||
const imgs = e?.detail;
|
||||
if (Array.isArray(imgs) && imgs.length) setStoryImages(imgs);
|
||||
};
|
||||
window.addEventListener('fbwriter:applyEdit', onApplyEdit as any);
|
||||
window.addEventListener('fbwriter:adVariations', onAdVariations as any);
|
||||
window.addEventListener('fbwriter:storyImages', onStoryImages as any);
|
||||
return () => {
|
||||
window.removeEventListener('fbwriter:updateDraft', onUpdate as any);
|
||||
window.removeEventListener('fbwriter:appendDraft', onAppend as any);
|
||||
window.removeEventListener('fbwriter:assistantMessage', onAssistantMessage as any);
|
||||
window.removeEventListener('fbwriter:applyEdit', onApplyEdit as any);
|
||||
window.removeEventListener('fbwriter:adVariations', onAdVariations as any);
|
||||
window.removeEventListener('fbwriter:storyImages', onStoryImages as any);
|
||||
};
|
||||
}, [postDraft]);
|
||||
|
||||
// Share current draft and notes with Copilot
|
||||
useCopilotReadable({
|
||||
@@ -31,6 +226,7 @@ const FacebookWriter: React.FC = () => {
|
||||
],
|
||||
handler: async ({ content }: { content: string }) => {
|
||||
setPostDraft(content);
|
||||
setStage('edit');
|
||||
return { success: true, message: 'Draft updated' };
|
||||
}
|
||||
});
|
||||
@@ -44,36 +240,115 @@ const FacebookWriter: React.FC = () => {
|
||||
],
|
||||
handler: async ({ content }: { content: string }) => {
|
||||
setPostDraft(prev => (prev ? `${prev}\n\n${content}` : content));
|
||||
setStage('edit');
|
||||
return { success: true, message: 'Text appended' };
|
||||
}
|
||||
});
|
||||
|
||||
const suggestions = [
|
||||
{ title: '🎉 Launch teaser', message: 'Write a short Facebook post announcing our new feature launch' },
|
||||
{ title: '💡 Benefit-first', message: 'Draft a Facebook post highlighting a key user benefit with a CTA' },
|
||||
{ title: '🔁 Variations', message: 'Generate 3 alternative versions of this post to A/B test' },
|
||||
{ title: '🏷️ Hashtags', message: 'Suggest 5 relevant hashtags for this post' }
|
||||
const startSuggestions = [
|
||||
{ title: '🎉 Launch teaser', message: 'Use tool generateFacebookPost to write a short Facebook post announcing our new feature launch.' },
|
||||
{ title: '💡 Benefit-first', message: 'Use tool generateFacebookPost to draft a benefit-first Facebook post with a strong CTA.' },
|
||||
{ title: '🏷️ Hashtags', message: 'Use tool generateFacebookHashtags to suggest 5 relevant hashtags for this post.' },
|
||||
{ title: '📢 Ad copy (primary text)', message: 'Use tool generateFacebookAdCopy to create ad copy tailored for conversions.' },
|
||||
{ title: '📚 Story', message: 'Use tool generateFacebookStory to create a Facebook Story script with tone and visuals.' },
|
||||
{ title: '🎬 Reel script', message: 'Use tool generateFacebookReel to draft a 30-60 seconds fast-paced product demo reel with hook, scenes, and CTA.' },
|
||||
{ title: '🖼️ Carousel', message: 'Use tool generateFacebookCarousel to create a 5-slide Product showcase carousel with a main caption and CTA.' },
|
||||
{ title: '📅 Event', message: 'Use tool generateFacebookEvent to create a Virtual Webinar event description with title, highlights, and CTA.' }
|
||||
];
|
||||
const editSuggestions = [
|
||||
{ title: '🙂 Make it casual', message: 'Use tool editFacebookDraft with operation Casual' },
|
||||
{ title: '💼 Make it professional', message: 'Use tool editFacebookDraft with operation Professional' },
|
||||
{ title: '✨ Tighten hook', message: 'Use tool editFacebookDraft with operation TightenHook' },
|
||||
{ title: '📣 Add a CTA', message: 'Use tool editFacebookDraft with operation AddCTA' },
|
||||
{ title: '✂️ Shorten', message: 'Use tool editFacebookDraft with operation Shorten' },
|
||||
{ title: '➕ Lengthen', message: 'Use tool editFacebookDraft with operation Lengthen' }
|
||||
];
|
||||
|
||||
// Stage-aware suggestion refinement
|
||||
const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|buy|shop)\b/i.test(postDraft);
|
||||
const hasHashtags = /#[A-Za-z0-9_]+/.test(postDraft);
|
||||
const isLong = (postDraft || '').length > 500;
|
||||
const refinedEdit = [
|
||||
...editSuggestions,
|
||||
...(isLong ? [{ title: '📝 Summarize intro', message: 'Use tool editFacebookDraft with operation Shorten' }] : []),
|
||||
...(!hasCTA ? [{ title: '📣 Add a CTA', message: 'Use tool editFacebookDraft with operation AddCTA' }] : []),
|
||||
...(!hasHashtags ? [{ title: '🏷️ Add hashtags', message: 'Use tool generateFacebookHashtags' }] : [])
|
||||
];
|
||||
const suggestions = stage === 'start' ? startSuggestions : refinedEdit;
|
||||
|
||||
return (
|
||||
<CopilotSidebar
|
||||
className="alwrity-copilot-sidebar"
|
||||
labels={{
|
||||
title: 'ALwrity • Facebook Writer',
|
||||
initial: 'Tell me what you want to post. I can draft, refine, and generate variants. I can also update the draft directly for you.'
|
||||
initial: stage === 'start' ? 'Tell me what you want to post. I can draft, refine, and generate variants.' : 'Great! Try quick edits below to refine your post in real-time.'
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(_context: string, additional?: string) => {
|
||||
const prefs = getPreferences();
|
||||
const prefsLine = Object.keys(prefs).length ? `User preferences (remember and respect unless changed): ${JSON.stringify(prefs)}` : '';
|
||||
const history = summarizeHistory();
|
||||
const historyLine = history ? `Recent conversation (last 10 messages):\n${history}` : '';
|
||||
const guidance = `
|
||||
You are ALwrity's Facebook Writing Assistant.
|
||||
You have the following tools available; prefer them when relevant:
|
||||
- generateFacebookPost: generate a Facebook post using provided business_type, target_audience, post_goal, post_tone, include, avoid.
|
||||
- generateFacebookHashtags: generate hashtags for a given content_topic (or infer from the user's draft/context).
|
||||
- updateFacebookPostDraft / appendToFacebookPostDraft: directly update the user's on-page draft when asked to tighten, rewrite, or append.
|
||||
- editFacebookDraft: apply a quick style or structural edit (Casual, Professional, Upbeat, Shorten, Lengthen, TightenHook, AddCTA) and reflect the change live.
|
||||
Always respond concisely and take action with the most appropriate tool.`.trim();
|
||||
return [prefsLine, historyLine, guidance, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
observabilityHooks={{
|
||||
onChatExpanded: () => console.log('[FB Writer] Sidebar opened'),
|
||||
onMessageSent: (m: any) => { console.log('[FB Writer] Message sent', m); try { const text = typeof m === 'string' ? m : (m?.content ?? ''); if (text) { pushHistory('user', String(text)); setHistoryVersion(v => v + 1); } } catch {} },
|
||||
onFeedbackGiven: (id: string, type: string) => console.log('[FB Writer] Feedback', { id, type })
|
||||
}}
|
||||
>
|
||||
<Box sx={{ py: 4 }}>
|
||||
<Container maxWidth="md">
|
||||
<Typography variant="h4" sx={{ color: 'white', fontWeight: 800, mb: 2 }}>
|
||||
Facebook Writer (Preview)
|
||||
</Typography>
|
||||
<RegisterFacebookActions />
|
||||
<RegisterFacebookEditActions />
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
position: 'relative',
|
||||
color: 'rgba(255,255,255,0.92)',
|
||||
background:
|
||||
'radial-gradient(1200px 600px at -10% -20%, rgba(24,119,242,0.25) 0%, transparent 60%),' +
|
||||
'radial-gradient(900px 500px at 110% 10%, rgba(11, 88, 195, 0.25) 0%, transparent 60%),' +
|
||||
'linear-gradient(135deg, #0b1a3a 0%, #0f2559 35%, #0f3a7a 70%, #0b4da6 100%)',
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="md" sx={{ position: 'relative', zIndex: 1, py: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 800, letterSpacing: 0.3 }}>
|
||||
Facebook Writer (Preview)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button size="small" variant="outlined" disabled sx={{ color: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,255,255,0.25)' }}>
|
||||
DashBoard
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => { clearHistory(); setHistoryVersion(v => v + 1); }}
|
||||
sx={{ color: 'rgba(255,255,255,0.9)', borderColor: 'rgba(255,255,255,0.35)' }}>
|
||||
Clear chat memory
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body1" sx={{ color: 'rgba(255,255,255,0.85)', mb: 3 }}>
|
||||
Collaborate with the Copilot to craft your post. The assistant can update the draft directly.
|
||||
{stage === 'start' ? 'Collaborate with the Copilot to craft your post. The assistant can update the draft directly.' : 'Use the edit suggestions to see real-time changes applied to your post.'}
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 2, background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.3)' }}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.14) 0%, rgba(255,255,255,0.08) 100%)',
|
||||
backdropFilter: 'blur(22px)',
|
||||
WebkitBackdropFilter: 'blur(22px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.16)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.25)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
|
||||
Context/Notes (optional)
|
||||
</Typography>
|
||||
@@ -87,32 +362,198 @@ const FacebookWriter: React.FC = () => {
|
||||
sx={{
|
||||
mb: 2,
|
||||
'& .MuiInputBase-root': { color: 'white' },
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.3)' }
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
|
||||
Post Draft
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={6}
|
||||
value={postDraft}
|
||||
onChange={e => setPostDraft(e.target.value)}
|
||||
placeholder="Your Facebook post will appear here. Ask the Copilot to draft or update it."
|
||||
sx={{
|
||||
'& .MuiInputBase-root': { color: 'white' },
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.3)' }
|
||||
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.35)' },
|
||||
'& .MuiInputBase-input::placeholder': { color: 'rgba(255,255,255,0.7)' }
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.14) 0%, rgba(255,255,255,0.08) 100%)',
|
||||
backdropFilter: 'blur(22px)',
|
||||
WebkitBackdropFilter: 'blur(22px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.16)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 18px 50px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.25)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)' }}>
|
||||
Post Draft (rendered)
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{isPreviewing && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 2,
|
||||
background: 'rgba(255,255,255,0.09)',
|
||||
border: '1px solid rgba(255,255,255,0.25)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
|
||||
Live changes preview
|
||||
</Typography>
|
||||
<div
|
||||
style={{ color: 'white', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}
|
||||
dangerouslySetInnerHTML={{ __html: livePreviewHtml }}
|
||||
/>
|
||||
<style>{`
|
||||
.fbw-add { color: #4CAF50; font-style: normal; background: rgba(76,175,80,0.12); border-radius: 3px; }
|
||||
.fbw-del { color: #EF9A9A; text-decoration: line-through; }
|
||||
.fbw-more { opacity: 0.7; }
|
||||
`}</style>
|
||||
{pendingEdit && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
|
||||
<Button size="small" variant="contained" color="primary"
|
||||
onClick={() => {
|
||||
setPostDraft(pendingEdit.target);
|
||||
setIsPreviewing(false);
|
||||
setPendingEdit(null);
|
||||
setLivePreviewHtml('');
|
||||
}}>
|
||||
Confirm changes
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" color="inherit"
|
||||
onClick={() => {
|
||||
setIsPreviewing(false);
|
||||
setPendingEdit(null);
|
||||
setLivePreviewHtml('');
|
||||
}}>
|
||||
Discard
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Box
|
||||
ref={renderRef}
|
||||
onMouseUp={() => {
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) { setSelectionMenu(null); return; }
|
||||
const text = (sel.toString() || '').trim();
|
||||
if (!text) { setSelectionMenu(null); return; }
|
||||
const range = sel.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
const container = renderRef.current?.getBoundingClientRect();
|
||||
if (!container) { setSelectionMenu(null); return; }
|
||||
const x = Math.max(8, rect.left - container.left + (rect.width / 2));
|
||||
const y = Math.max(8, rect.top - container.top);
|
||||
setSelectionMenu({ x, y, text });
|
||||
} catch {
|
||||
setSelectionMenu(null);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
borderRadius: 2,
|
||||
color: 'rgba(255,255,255,0.95)',
|
||||
position: 'relative',
|
||||
'& h1, & h2, & h3': { margin: '8px 0' },
|
||||
'& p': { margin: '6px 0' },
|
||||
'& ul': { paddingLeft: '1.2rem', margin: '6px 0' }
|
||||
}}>
|
||||
<div dangerouslySetInnerHTML={{ __html: simpleMarkdownToHtml(postDraft) }} />
|
||||
{selectionMenu && (
|
||||
<Box
|
||||
role="menu"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: selectionMenu.y - 36,
|
||||
left: selectionMenu.x - 80,
|
||||
background: 'rgba(20,22,35,0.92)',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
borderRadius: 2,
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
boxShadow: '0 10px 24px rgba(0,0,0,0.35)'
|
||||
}}
|
||||
>
|
||||
<Button size="small" variant="text" sx={{ color: 'white', textTransform: 'none' }} onClick={() => console.log('Casual:', selectionMenu.text)}>Casual</Button>
|
||||
<Button size="small" variant="text" sx={{ color: 'white', textTransform: 'none' }} onClick={() => console.log('Shorten:', selectionMenu.text)}>Shorten</Button>
|
||||
<Button size="small" variant="text" sx={{ color: 'white', textTransform: 'none' }} onClick={() => console.log('Professional:', selectionMenu.text)}>Professional</Button>
|
||||
<Button size="small" variant="text" sx={{ color: 'rgba(255,255,255,0.8)', textTransform: 'none' }} onClick={() => setSelectionMenu(null)}>Close</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{Array.isArray(storyImages) && storyImages.length > 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
mt: 3,
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.06) 100%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.16)',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
|
||||
Story Images
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{storyImages.map((b64, idx) => (
|
||||
<img key={idx} src={`data:image/png;base64,${b64}`} alt={`story-${idx}`} style={{ maxWidth: 220, borderRadius: 8, border: '1px solid rgba(255,255,255,0.2)' }} />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{adVariations && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
mt: 3,
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.06) 100%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.16)',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
|
||||
Ad Variations
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
||||
<VariationList title="Headlines" items={adVariations.headline_variations} onInsert={(t)=>setPostDraft(prev => prev ? `${t}\n\n${prev}` : t)} onReplace={(t)=>setPostDraft(t)} />
|
||||
<VariationList title="Primary Text" items={adVariations.primary_text_variations} onInsert={(t)=>setPostDraft(prev => prev ? `${prev}\n\n${t}` : t)} onReplace={(t)=>setPostDraft(t)} />
|
||||
<VariationList title="Descriptions" items={adVariations.description_variations} onInsert={(t)=>setPostDraft(prev => prev ? `${prev}\n\n${t}` : t)} onReplace={(t)=>setPostDraft(t)} />
|
||||
<VariationList title="CTAs" items={adVariations.cta_variations} onInsert={(t)=>setPostDraft(prev => prev ? `${prev}\n\n${t}` : t)} onReplace={(t)=>setPostDraft(t)} />
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
</CopilotSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
const VariationList: React.FC<{ title: string; items: string[]; onInsert: (t: string) => void; onReplace: (t: string) => void }> = ({ title, items, onInsert, onReplace }) => {
|
||||
if (!Array.isArray(items) || items.length === 0) return null;
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.85)', mb: 1 }}>{title}</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{items.slice(0, 5).map((t, idx) => (
|
||||
<Box key={idx} sx={{ border: '1px solid rgba(255,255,255,0.18)', borderRadius: 2, p: 1.2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>{t}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button size="small" variant="contained" onClick={() => onInsert(t)}>Insert</Button>
|
||||
<Button size="small" variant="outlined" onClick={() => onReplace(t)}>Replace</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FacebookWriter;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,991 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { facebookWriterApi, PostGenerateRequest } from '../../services/facebookWriterApi';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
// Persist minimal user preferences for better defaults across sessions
|
||||
const PREFS_KEY = 'fbwriter:preferences';
|
||||
function readPrefs(): Record<string, any> { try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {}; } catch { return {}; } }
|
||||
function writePrefs(p: Record<string, any>) { try { localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch {} }
|
||||
|
||||
// Allow assistant to log messages into history (handled in FacebookWriter via event)
|
||||
function logAssistant(content: string) {
|
||||
try { window.dispatchEvent(new CustomEvent('fbwriter:assistantMessage', { detail: { content } })); } catch {}
|
||||
}
|
||||
|
||||
const VALID_GOALS = [
|
||||
'Promote a product/service',
|
||||
'Share valuable content',
|
||||
'Increase engagement',
|
||||
'Build brand awareness',
|
||||
'Drive website traffic',
|
||||
'Generate leads',
|
||||
'Announce news/updates',
|
||||
'Custom'
|
||||
];
|
||||
|
||||
const VALID_TONES = [
|
||||
'Informative',
|
||||
'Humorous',
|
||||
'Inspirational',
|
||||
'Upbeat',
|
||||
'Casual',
|
||||
'Professional',
|
||||
'Conversational',
|
||||
'Custom'
|
||||
];
|
||||
|
||||
function normalizeEnum(input: string | undefined | null): string {
|
||||
return (input || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function mapGoal(goal: string | undefined): string {
|
||||
const g = normalizeEnum(goal);
|
||||
if (!g) return 'Build brand awareness';
|
||||
const exact = VALID_GOALS.find(v => v.toLowerCase() === g);
|
||||
if (exact) return exact;
|
||||
if (g.includes('announce')) return 'Announce news/updates';
|
||||
if (g.includes('awareness') || g.includes('brand')) return 'Build brand awareness';
|
||||
if (g.includes('engagement') || g.includes('engage')) return 'Increase engagement';
|
||||
if (g.includes('traffic')) return 'Drive website traffic';
|
||||
if (g.includes('lead')) return 'Generate leads';
|
||||
if (g.includes('share') || g.includes('content')) return 'Share valuable content';
|
||||
if (g.includes('promote') || g.includes('product') || g.includes('service')) return 'Promote a product/service';
|
||||
return 'Build brand awareness';
|
||||
}
|
||||
|
||||
function mapTone(tone: string | undefined): string {
|
||||
const t = normalizeEnum(tone);
|
||||
if (!t) return 'Professional';
|
||||
const exact = VALID_TONES.find(v => v.toLowerCase() === t);
|
||||
if (exact) return exact;
|
||||
if (t.includes('friendly') || t.includes('casual')) return 'Casual';
|
||||
if (t.includes('professional') || t.includes('pro')) return 'Professional';
|
||||
if (t.includes('exciting') || t.includes('energetic') || t.includes('upbeat')) return 'Upbeat';
|
||||
if (t.includes('inspir')) return 'Inspirational';
|
||||
if (t.includes('humor') || t.includes('funny')) return 'Humorous';
|
||||
if (t.includes('convers')) return 'Conversational';
|
||||
if (t.includes('info')) return 'Informative';
|
||||
return 'Professional';
|
||||
}
|
||||
|
||||
const PostHITL: React.FC<{ args: any; respond: (data: any) => void }> = ({ args, respond }) => {
|
||||
const prefs = React.useMemo(() => readPrefs(), []);
|
||||
const [form, setForm] = React.useState<PostGenerateRequest>({
|
||||
business_type: args?.business_type || prefs.business_type || 'SaaS',
|
||||
target_audience: args?.target_audience || prefs.target_audience || 'Marketing managers at SMEs',
|
||||
post_goal: args?.post_goal || prefs.post_goal || 'Build brand awareness',
|
||||
post_tone: args?.post_tone || prefs.post_tone || 'Professional',
|
||||
include: args?.include || prefs.include || '',
|
||||
avoid: args?.avoid || prefs.avoid || '',
|
||||
media_type: args?.media_type || 'None',
|
||||
advanced_options: { use_hook: true, use_story: true, use_cta: true, use_question: true, use_emoji: true, use_hashtags: true }
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const payload: PostGenerateRequest = {
|
||||
...form,
|
||||
post_goal: mapGoal(form.post_goal),
|
||||
post_tone: mapTone(form.post_tone),
|
||||
media_type: 'None'
|
||||
};
|
||||
// Save user preference snapshot
|
||||
writePrefs({
|
||||
business_type: payload.business_type,
|
||||
target_audience: payload.target_audience,
|
||||
post_goal: payload.post_goal,
|
||||
post_tone: payload.post_tone,
|
||||
include: payload.include,
|
||||
avoid: payload.avoid
|
||||
});
|
||||
const res = await facebookWriterApi.postGenerate(payload);
|
||||
const content = res?.content || res?.data?.content;
|
||||
if (content) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:updateDraft', { detail: content }));
|
||||
logAssistant(content);
|
||||
respond({ success: true, content });
|
||||
} else {
|
||||
respond({ success: true, message: 'Post generated.' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || 'Failed to generate post';
|
||||
const tip = `Tip: goals must be one of ${VALID_GOALS.join(', ')}; tones must be one of ${VALID_TONES.join(', ')}.`;
|
||||
setError(`${msg}`);
|
||||
respond({ success: false, message: `${msg}` });
|
||||
console.error('[FB Writer] post.generate error', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (k: keyof PostGenerateRequest, v: any) => setForm(prev => ({ ...prev, [k]: v }));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Generate Facebook Post</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<input placeholder={`Business type`} value={form.business_type} onChange={e => set('business_type', e.target.value)} />
|
||||
<input placeholder={`Target audience`} value={form.target_audience} onChange={e => set('target_audience', e.target.value)} />
|
||||
<input placeholder={`Goal (e.g., ${VALID_GOALS[3]})`} value={form.post_goal} onChange={e => set('post_goal', e.target.value)} />
|
||||
<input placeholder={`Tone (e.g., ${VALID_TONES[5]})`} value={form.post_tone} onChange={e => set('post_tone', e.target.value)} />
|
||||
<input placeholder="Include" value={form.include || ''} onChange={e => set('include', e.target.value)} />
|
||||
<input placeholder="Avoid" value={form.avoid || ''} onChange={e => set('avoid', e.target.value)} />
|
||||
</div>
|
||||
<button onClick={run} disabled={loading} style={{ marginTop: 8 }}>{loading ? 'Generating…' : 'Generate'}</button>
|
||||
{error && <div style={{ marginTop: 8, color: '#c33', fontSize: 12 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HashtagsHITL: React.FC<{ args: any; respond: (data: any) => void }> = ({ args, respond }) => {
|
||||
const [topic, setTopic] = React.useState<string>(args?.content_topic || 'product launch');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await facebookWriterApi.hashtagsGenerate({ content_topic: topic });
|
||||
const hashtags = res?.hashtags || res?.data?.hashtags;
|
||||
if (Array.isArray(hashtags) && hashtags.length) {
|
||||
const line = hashtags.join(' ');
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: `\n\n${line}` }));
|
||||
logAssistant(line);
|
||||
respond({ success: true, hashtags });
|
||||
} else {
|
||||
respond({ success: true, message: 'Hashtags generated.' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || 'Failed to generate hashtags';
|
||||
setError(`${msg}`);
|
||||
respond({ success: false, message: `${msg}` });
|
||||
console.error('[FB Writer] hashtags.generate error', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Generate Hashtags</div>
|
||||
<input placeholder="Topic" value={topic} onChange={e => setTopic(e.target.value)} />
|
||||
<button onClick={run} disabled={loading} style={{ marginLeft: 8 }}>{loading ? 'Generating…' : 'Generate'}</button>
|
||||
{error && <div style={{ marginTop: 8, color: '#c33', fontSize: 12 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RegisterFacebookActions: React.FC = () => {
|
||||
useCopilotActionTyped({
|
||||
name: 'generateFacebookEvent',
|
||||
description: 'Generate a Facebook Event title and description',
|
||||
parameters: [
|
||||
{ name: 'event_name', type: 'string', required: false },
|
||||
{ name: 'event_type', type: 'string', required: false },
|
||||
{ name: 'event_format', type: 'string', required: false },
|
||||
{ name: 'business_type', type: 'string', required: false },
|
||||
{ name: 'target_audience', type: 'string', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ args, respond }: any) => {
|
||||
const TYPES = ['Workshop','Webinar','Conference','Networking event','Product launch','Sale/Promotion','Community event','Educational event','Custom'];
|
||||
const FORMATS = ['In-person','Virtual','Hybrid'];
|
||||
const mapType = (t?: string) => {
|
||||
const s = (t || '').trim().toLowerCase();
|
||||
const exact = TYPES.find(v => v.toLowerCase() === s);
|
||||
if (exact) return exact;
|
||||
if (s.includes('web')) return 'Webinar';
|
||||
if (s.includes('work')) return 'Workshop';
|
||||
if (s.includes('network')) return 'Networking event';
|
||||
if (s.includes('launch')) return 'Product launch';
|
||||
if (s.includes('sale') || s.includes('promo')) return 'Sale/Promotion';
|
||||
if (s.includes('communi')) return 'Community event';
|
||||
if (s.includes('educat')) return 'Educational event';
|
||||
if (s.includes('conf')) return 'Conference';
|
||||
return 'Webinar';
|
||||
};
|
||||
const mapFormat = (f?: string) => {
|
||||
const s = (f || '').trim().toLowerCase();
|
||||
const exact = FORMATS.find(v => v.toLowerCase() === s);
|
||||
if (exact) return exact;
|
||||
if (s.includes('in') || s.includes('person')) return 'In-person';
|
||||
if (s.includes('hybr')) return 'Hybrid';
|
||||
return 'Virtual';
|
||||
};
|
||||
const EventHITL: React.FC = () => {
|
||||
const [form, setForm] = React.useState({
|
||||
event_name: args?.event_name || 'Monthly Growth Webinar',
|
||||
event_type: mapType(args?.event_type) || 'Webinar',
|
||||
event_format: mapFormat(args?.event_format) || 'Virtual',
|
||||
business_type: args?.business_type || 'SaaS',
|
||||
target_audience: args?.target_audience || 'Marketing managers at SMEs',
|
||||
event_date: args?.event_date || '',
|
||||
event_time: args?.event_time || '',
|
||||
location: args?.location || '',
|
||||
duration: args?.duration || '60 minutes',
|
||||
key_benefits: args?.key_benefits || '',
|
||||
speakers: args?.speakers || '',
|
||||
agenda: args?.agenda || '',
|
||||
ticket_info: args?.ticket_info || '',
|
||||
special_offers: args?.special_offers || '',
|
||||
include: args?.include || '',
|
||||
avoid: args?.avoid || ''
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const payload = { ...form, event_type: mapType(form.event_type), event_format: mapFormat(form.event_format) } as any;
|
||||
const res = await facebookWriterApi.eventGenerate(payload);
|
||||
const title = res?.event_title || res?.data?.event_title;
|
||||
const desc = res?.event_description || res?.data?.event_description;
|
||||
let out = '';
|
||||
if (title) out += `\n\n${title}`;
|
||||
if (desc) out += `\n\n${desc}`;
|
||||
if (out) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: out }));
|
||||
respond({ success: true, content: out });
|
||||
} else {
|
||||
respond({ success: true, message: 'Event generated.' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || 'Failed to generate event';
|
||||
setError(`${msg}`);
|
||||
respond({ success: false, message: `${msg}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const set = (k: string, v: any) => setForm((p: any) => ({ ...p, [k]: v }));
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Generate Event</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<input placeholder="Event name" value={form.event_name} onChange={e => set('event_name', e.target.value)} />
|
||||
<input placeholder="Event type (e.g., Webinar)" value={form.event_type} onChange={e => set('event_type', e.target.value)} />
|
||||
<input placeholder="Format (In-person/Virtual/Hybrid)" value={form.event_format} onChange={e => set('event_format', e.target.value)} />
|
||||
<input placeholder="Business type" value={form.business_type} onChange={e => set('business_type', e.target.value)} />
|
||||
<input placeholder="Target audience" value={form.target_audience} onChange={e => set('target_audience', e.target.value)} />
|
||||
<input placeholder="Date (YYYY-MM-DD)" value={form.event_date} onChange={e => set('event_date', e.target.value)} />
|
||||
<input placeholder="Time" value={form.event_time} onChange={e => set('event_time', e.target.value)} />
|
||||
<input placeholder="Location" value={form.location} onChange={e => set('location', e.target.value)} />
|
||||
<input placeholder="Duration" value={form.duration} onChange={e => set('duration', e.target.value)} />
|
||||
<input placeholder="Key benefits" value={form.key_benefits} onChange={e => set('key_benefits', e.target.value)} />
|
||||
<input placeholder="Speakers" value={form.speakers} onChange={e => set('speakers', e.target.value)} />
|
||||
<input placeholder="Agenda" value={form.agenda} onChange={e => set('agenda', e.target.value)} />
|
||||
<input placeholder="Ticket info" value={form.ticket_info} onChange={e => set('ticket_info', e.target.value)} />
|
||||
<input placeholder="Special offers" value={form.special_offers} onChange={e => set('special_offers', e.target.value)} />
|
||||
<input placeholder="Include" value={form.include} onChange={e => set('include', e.target.value)} />
|
||||
<input placeholder="Avoid" value={form.avoid} onChange={e => set('avoid', e.target.value)} />
|
||||
</div>
|
||||
<button onClick={run} disabled={loading} style={{ marginTop: 8 }}>{loading ? 'Generating…' : 'Generate'}</button>
|
||||
{error && <div style={{ marginTop: 8, color: '#c33', fontSize: 12 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <EventHITL />;
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const TYPES = ['Workshop','Webinar','Conference','Networking event','Product launch','Sale/Promotion','Community event','Educational event','Custom'];
|
||||
const FORMATS = ['In-person','Virtual','Hybrid'];
|
||||
const map = (arr: string[], v: string | undefined, def: string) => {
|
||||
const s = (v || '').trim().toLowerCase();
|
||||
const exact = arr.find(x => x.toLowerCase() === s);
|
||||
return exact || def;
|
||||
};
|
||||
const res = await facebookWriterApi.eventGenerate({
|
||||
event_name: args?.event_name || 'Monthly Growth Webinar',
|
||||
event_type: map(TYPES, args?.event_type, 'Webinar'),
|
||||
event_format: map(FORMATS, args?.event_format, 'Virtual'),
|
||||
business_type: args?.business_type || 'SaaS',
|
||||
target_audience: args?.target_audience || 'Marketing managers at SMEs',
|
||||
event_date: args?.event_date,
|
||||
event_time: args?.event_time,
|
||||
location: args?.location,
|
||||
duration: args?.duration,
|
||||
key_benefits: args?.key_benefits,
|
||||
speakers: args?.speakers,
|
||||
agenda: args?.agenda,
|
||||
ticket_info: args?.ticket_info,
|
||||
special_offers: args?.special_offers,
|
||||
include: args?.include,
|
||||
avoid: args?.avoid
|
||||
});
|
||||
const title = res?.event_title || res?.data?.event_title;
|
||||
const desc = res?.event_description || res?.data?.event_description;
|
||||
let out = '';
|
||||
if (title) out += `\n\n${title}`;
|
||||
if (desc) out += `\n\n${desc}`;
|
||||
if (out) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: out }));
|
||||
return { success: true, content: out };
|
||||
}
|
||||
return { success: true, message: 'Event generated.' };
|
||||
}
|
||||
});
|
||||
useCopilotActionTyped({
|
||||
name: 'generateFacebookCarousel',
|
||||
description: 'Generate a Facebook Carousel with slides and a main caption',
|
||||
parameters: [
|
||||
{ name: 'business_type', type: 'string', required: false },
|
||||
{ name: 'target_audience', type: 'string', required: false },
|
||||
{ name: 'carousel_type', type: 'string', required: false },
|
||||
{ name: 'topic', type: 'string', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ args, respond }: any) => {
|
||||
const VALID_TYPES = ['Product showcase','Step-by-step guide','Before/After','Customer testimonials','Features & Benefits','Portfolio showcase','Educational content','Custom'];
|
||||
const mapType = (t?: string) => {
|
||||
const s = (t || '').trim().toLowerCase();
|
||||
const exact = VALID_TYPES.find(v => v.toLowerCase() === s);
|
||||
if (exact) return exact;
|
||||
if (s.includes('step')) return 'Step-by-step guide';
|
||||
if (s.includes('before') || s.includes('after')) return 'Before/After';
|
||||
if (s.includes('testi')) return 'Customer testimonials';
|
||||
if (s.includes('feature') || s.includes('benefit')) return 'Features & Benefits';
|
||||
if (s.includes('portfolio')) return 'Portfolio showcase';
|
||||
if (s.includes('educat')) return 'Educational content';
|
||||
return 'Product showcase';
|
||||
};
|
||||
const CarouselHITL: React.FC = () => {
|
||||
const prefs = React.useMemo(() => readPrefs(), []);
|
||||
const [form, setForm] = React.useState({
|
||||
business_type: args?.business_type || prefs.business_type || 'SaaS',
|
||||
target_audience: args?.target_audience || prefs.target_audience || 'Marketing managers at SMEs',
|
||||
carousel_type: args?.carousel_type || 'Product showcase',
|
||||
topic: args?.topic || 'Feature breakdown',
|
||||
num_slides: 5,
|
||||
include_cta: true,
|
||||
cta_text: '',
|
||||
brand_colors: '',
|
||||
include: '',
|
||||
avoid: ''
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const payload = { ...form, carousel_type: mapType(form.carousel_type) } as any;
|
||||
const res = await facebookWriterApi.carouselGenerate(payload);
|
||||
const main = res?.main_caption || res?.data?.main_caption;
|
||||
const slides = res?.slides || res?.data?.slides;
|
||||
let out = '';
|
||||
if (main) out += `\n\n${main}`;
|
||||
if (Array.isArray(slides)) {
|
||||
out += '\n\nCarousel Slides:';
|
||||
slides.forEach((s: any, i: number) => {
|
||||
out += `\n${i + 1}. ${s.title}: ${s.content}`;
|
||||
});
|
||||
}
|
||||
if (out) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: out }));
|
||||
logAssistant(out);
|
||||
respond({ success: true, content: out });
|
||||
} else {
|
||||
respond({ success: true, message: 'Carousel generated.' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || 'Failed to generate carousel';
|
||||
setError(`${msg}`);
|
||||
respond({ success: false, message: `${msg}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const set = (k: string, v: any) => setForm((p: any) => ({ ...p, [k]: v }));
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Generate Carousel</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<input placeholder="Business type" value={form.business_type} onChange={e => set('business_type', e.target.value)} />
|
||||
<input placeholder="Target audience" value={form.target_audience} onChange={e => set('target_audience', e.target.value)} />
|
||||
<input placeholder="Carousel type (e.g., Product showcase)" value={form.carousel_type} onChange={e => set('carousel_type', e.target.value)} />
|
||||
<input placeholder="Topic" value={form.topic} onChange={e => set('topic', e.target.value)} />
|
||||
<input placeholder="Number of slides (3-10)" value={form.num_slides} onChange={e => set('num_slides', Number(e.target.value) || 5)} />
|
||||
<label><input type="checkbox" checked={!!form.include_cta} onChange={e => set('include_cta', e.target.checked)} /> Include CTA</label>
|
||||
<input placeholder="CTA text" value={form.cta_text} onChange={e => set('cta_text', e.target.value)} />
|
||||
<input placeholder="Brand colors" value={form.brand_colors} onChange={e => set('brand_colors', e.target.value)} />
|
||||
<input placeholder="Include" value={form.include} onChange={e => set('include', e.target.value)} />
|
||||
<input placeholder="Avoid" value={form.avoid} onChange={e => set('avoid', e.target.value)} />
|
||||
</div>
|
||||
<button onClick={run} disabled={loading} style={{ marginTop: 8 }}>{loading ? 'Generating…' : 'Generate'}</button>
|
||||
{error && <div style={{ marginTop: 8, color: '#c33', fontSize: 12 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <CarouselHITL />;
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const VALID_TYPES = ['Product showcase','Step-by-step guide','Before/After','Customer testimonials','Features & Benefits','Portfolio showcase','Educational content','Custom'];
|
||||
const mapType = (t?: string) => {
|
||||
const s = (t || '').trim().toLowerCase();
|
||||
const exact = VALID_TYPES.find(v => v.toLowerCase() === s);
|
||||
return exact || 'Product showcase';
|
||||
};
|
||||
const res = await facebookWriterApi.carouselGenerate({
|
||||
business_type: args?.business_type,
|
||||
target_audience: args?.target_audience,
|
||||
carousel_type: mapType(args?.carousel_type),
|
||||
topic: args?.topic || 'Feature breakdown',
|
||||
num_slides: Math.min(10, Math.max(3, Number(args?.num_slides) || 5)),
|
||||
include_cta: args?.include_cta,
|
||||
cta_text: args?.cta_text,
|
||||
brand_colors: args?.brand_colors,
|
||||
include: args?.include,
|
||||
avoid: args?.avoid
|
||||
});
|
||||
const main = res?.main_caption || res?.data?.main_caption;
|
||||
const slides = res?.slides || res?.data?.slides;
|
||||
let out = '';
|
||||
if (main) out += `\n\n${main}`;
|
||||
if (Array.isArray(slides)) {
|
||||
out += '\n\nCarousel Slides:';
|
||||
slides.forEach((s: any, i: number) => {
|
||||
out += `\n${i + 1}. ${s.title}: ${s.content}`;
|
||||
});
|
||||
}
|
||||
if (out) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: out }));
|
||||
return { success: true, content: out };
|
||||
}
|
||||
return { success: true, message: 'Carousel generated.' };
|
||||
}
|
||||
});
|
||||
useCopilotActionTyped({
|
||||
name: 'generateFacebookReel',
|
||||
description: 'Generate a Facebook Reel script with scene breakdown and music suggestions',
|
||||
parameters: [
|
||||
{ name: 'business_type', type: 'string', required: false },
|
||||
{ name: 'target_audience', type: 'string', required: false },
|
||||
{ name: 'reel_type', type: 'string', required: false },
|
||||
{ name: 'reel_length', type: 'string', required: false },
|
||||
{ name: 'reel_style', type: 'string', required: false },
|
||||
{ name: 'topic', type: 'string', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ args, respond }: any) => {
|
||||
const VALID_REEL_TYPES = ['Product demonstration','Tutorial/How-to','Entertainment','Educational','Trend-based','Behind the scenes','User-generated content','Custom'];
|
||||
const VALID_REEL_LENGTHS = ['15-30 seconds','30-60 seconds','60-90 seconds'];
|
||||
const VALID_REEL_STYLES = ['Fast-paced','Relaxed','Dramatic','Minimalist','Vibrant','Custom'];
|
||||
const mapReelType = (t?: string) => {
|
||||
const s = (t || '').trim().toLowerCase();
|
||||
const exact = VALID_REEL_TYPES.find(v => v.toLowerCase() === s);
|
||||
if (exact) return exact;
|
||||
if (s.includes('product')) return 'Product demonstration';
|
||||
if (s.includes('tutorial') || s.includes('how')) return 'Tutorial/How-to';
|
||||
if (s.includes('behind')) return 'Behind the scenes';
|
||||
if (s.includes('user')) return 'User-generated content';
|
||||
if (s.includes('trend')) return 'Trend-based';
|
||||
if (s.includes('educat')) return 'Educational';
|
||||
if (s.includes('entertain')) return 'Entertainment';
|
||||
return 'Product demonstration';
|
||||
};
|
||||
const mapReelLength = (l?: string) => {
|
||||
const s = (l || '').trim().toLowerCase();
|
||||
const exact = VALID_REEL_LENGTHS.find(v => v.toLowerCase() === s);
|
||||
if (exact) return exact;
|
||||
if (s.includes('15')) return '15-30 seconds';
|
||||
if (s.includes('60') || s.includes('30-60')) return '30-60 seconds';
|
||||
if (s.includes('90') || s.includes('60-90')) return '60-90 seconds';
|
||||
return '30-60 seconds';
|
||||
};
|
||||
const mapReelStyle = (st?: string) => {
|
||||
const s = (st || '').trim().toLowerCase();
|
||||
const exact = VALID_REEL_STYLES.find(v => v.toLowerCase() === s);
|
||||
if (exact) return exact;
|
||||
if (s.includes('fast')) return 'Fast-paced';
|
||||
if (s.includes('relax')) return 'Relaxed';
|
||||
if (s.includes('dram')) return 'Dramatic';
|
||||
if (s.includes('mini')) return 'Minimalist';
|
||||
if (s.includes('vibr')) return 'Vibrant';
|
||||
return 'Fast-paced';
|
||||
};
|
||||
const ReelHITL: React.FC = () => {
|
||||
const prefs = React.useMemo(() => readPrefs(), []);
|
||||
const [form, setForm] = React.useState({
|
||||
business_type: args?.business_type || prefs.business_type || 'SaaS',
|
||||
target_audience: args?.target_audience || prefs.target_audience || 'Marketing managers at SMEs',
|
||||
reel_type: args?.reel_type || 'Product demonstration',
|
||||
reel_length: args?.reel_length || '30-60 seconds',
|
||||
reel_style: args?.reel_style || 'Fast-paced',
|
||||
topic: args?.topic || 'Feature walkthrough',
|
||||
include: args?.include || '',
|
||||
avoid: args?.avoid || '',
|
||||
music_preference: args?.music_preference || ''
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const payload = {
|
||||
...form,
|
||||
reel_type: mapReelType(form.reel_type),
|
||||
reel_length: mapReelLength(form.reel_length),
|
||||
reel_style: mapReelStyle(form.reel_style)
|
||||
} as any;
|
||||
const res = await facebookWriterApi.reelGenerate(payload);
|
||||
const script = res?.script || res?.data?.script;
|
||||
if (script) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: `\n\n${script}` }));
|
||||
logAssistant(script);
|
||||
respond({ success: true, content: script });
|
||||
} else {
|
||||
respond({ success: true, message: 'Reel generated.' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || 'Failed to generate reel. Tip: type should be one of ' + VALID_REEL_TYPES.join(', ') + '; length one of ' + VALID_REEL_LENGTHS.join(', ') + '; style one of ' + VALID_REEL_STYLES.join(', ') + '.';
|
||||
setError(`${msg}`);
|
||||
respond({ success: false, message: `${msg}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const set = (k: string, v: any) => setForm((p: any) => ({ ...p, [k]: v }));
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Generate Reel</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<input placeholder="Business type" value={form.business_type} onChange={e => set('business_type', e.target.value)} />
|
||||
<input placeholder="Target audience" value={form.target_audience} onChange={e => set('target_audience', e.target.value)} />
|
||||
<input placeholder="Reel type (e.g., Product demonstration)" value={form.reel_type} onChange={e => set('reel_type', e.target.value)} />
|
||||
<input placeholder="Length (e.g., 30-60 seconds)" value={form.reel_length} onChange={e => set('reel_length', e.target.value)} />
|
||||
<input placeholder="Style (e.g., Fast-paced)" value={form.reel_style} onChange={e => set('reel_style', e.target.value)} />
|
||||
<input placeholder="Topic" value={form.topic} onChange={e => set('topic', e.target.value)} />
|
||||
<input placeholder="Include" value={form.include} onChange={e => set('include', e.target.value)} />
|
||||
<input placeholder="Avoid" value={form.avoid} onChange={e => set('avoid', e.target.value)} />
|
||||
<input placeholder="Music preference" value={form.music_preference} onChange={e => set('music_preference', e.target.value)} />
|
||||
</div>
|
||||
<button onClick={run} disabled={loading} style={{ marginTop: 8 }}>{loading ? 'Generating…' : 'Generate'}</button>
|
||||
{error && <div style={{ marginTop: 8, color: '#c33', fontSize: 12 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return <ReelHITL />;
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
const VALID_REEL_TYPES = ['Product demonstration','Tutorial/How-to','Entertainment','Educational','Trend-based','Behind the scenes','User-generated content','Custom'];
|
||||
const VALID_REEL_LENGTHS = ['15-30 seconds','30-60 seconds','60-90 seconds'];
|
||||
const VALID_REEL_STYLES = ['Fast-paced','Relaxed','Dramatic','Minimalist','Vibrant','Custom'];
|
||||
const map = (arr: string[], s: string | undefined, fallback: string) => {
|
||||
const t = (s || '').trim().toLowerCase();
|
||||
const exact = arr.find(v => v.toLowerCase() === t);
|
||||
return exact || fallback;
|
||||
};
|
||||
const res = await facebookWriterApi.reelGenerate({
|
||||
business_type: args?.business_type,
|
||||
target_audience: args?.target_audience,
|
||||
reel_type: map(VALID_REEL_TYPES, args?.reel_type, 'Product demonstration'),
|
||||
reel_length: map(VALID_REEL_LENGTHS, args?.reel_length, '30-60 seconds'),
|
||||
reel_style: map(VALID_REEL_STYLES, args?.reel_style, 'Fast-paced'),
|
||||
topic: args?.topic || 'Feature walkthrough',
|
||||
include: args?.include,
|
||||
avoid: args?.avoid,
|
||||
music_preference: args?.music_preference
|
||||
});
|
||||
const script = res?.script || res?.data?.script;
|
||||
if (script) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: `\n\n${script}` }));
|
||||
return { success: true, content: script };
|
||||
}
|
||||
return { success: true, message: 'Reel generated.' };
|
||||
}
|
||||
});
|
||||
useCopilotActionTyped({
|
||||
name: 'generateFacebookPost',
|
||||
description: 'Generate a Facebook post using AI',
|
||||
parameters: [
|
||||
{ name: 'business_type', type: 'string', required: false },
|
||||
{ name: 'target_audience', type: 'string', required: false },
|
||||
{ name: 'post_goal', type: 'string', required: false },
|
||||
{ name: 'post_tone', type: 'string', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ args, respond }: any) => <PostHITL args={args} respond={respond} />,
|
||||
handler: async (args: any) => {
|
||||
const payload: PostGenerateRequest = {
|
||||
...(args || {}),
|
||||
post_goal: mapGoal(args?.post_goal),
|
||||
post_tone: mapTone(args?.post_tone),
|
||||
media_type: 'None'
|
||||
};
|
||||
const res = await facebookWriterApi.postGenerate(payload);
|
||||
const content = res?.content || res?.data?.content;
|
||||
if (content) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:updateDraft', { detail: content }));
|
||||
return { success: true, content };
|
||||
}
|
||||
return { success: true, message: 'Post generated.' };
|
||||
}
|
||||
});
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateFacebookHashtags',
|
||||
description: 'Generate relevant hashtags for the content',
|
||||
parameters: [
|
||||
{ name: 'content_topic', type: 'string', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ args, respond }: any) => <HashtagsHITL args={args} respond={respond} />,
|
||||
handler: async (args: any) => {
|
||||
const res = await facebookWriterApi.hashtagsGenerate({ content_topic: args?.content_topic });
|
||||
const hashtags = res?.hashtags || res?.data?.hashtags;
|
||||
if (Array.isArray(hashtags) && hashtags.length) {
|
||||
const line = hashtags.join(' ');
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: `\n\n${line}` }));
|
||||
return { success: true, hashtags };
|
||||
}
|
||||
return { success: true, message: 'Hashtags generated.' };
|
||||
}
|
||||
});
|
||||
|
||||
// Ad Copy generation (M3)
|
||||
const AdCopyHITL: React.FC<{ args: any; respond?: (data: any) => void }> = ({ args, respond }) => {
|
||||
const prefs = React.useMemo(() => readPrefs(), []);
|
||||
const [form, setForm] = React.useState({
|
||||
business_type: args?.business_type || prefs.business_type || 'SaaS',
|
||||
product_service: args?.product_service || 'Product X',
|
||||
ad_objective: args?.ad_objective || 'Conversions',
|
||||
ad_format: args?.ad_format || 'Single image',
|
||||
target_audience: args?.target_audience || prefs.target_audience || 'Marketing managers at SMEs',
|
||||
targeting_options: {
|
||||
age_group: (args?.targeting_options?.age_group) || '18-24',
|
||||
gender: args?.targeting_options?.gender || 'All',
|
||||
location: args?.targeting_options?.location || 'Global',
|
||||
interests: args?.targeting_options?.interests || '',
|
||||
behaviors: args?.targeting_options?.behaviors || '',
|
||||
lookalike_audience: args?.targeting_options?.lookalike_audience || ''
|
||||
},
|
||||
unique_selling_proposition: args?.unique_selling_proposition || 'Fast, reliable, loved by users',
|
||||
offer_details: args?.offer_details || '',
|
||||
budget_range: args?.budget_range || '$50-200/day',
|
||||
custom_budget: args?.custom_budget || '',
|
||||
campaign_duration: args?.campaign_duration || '2 weeks',
|
||||
competitor_analysis: args?.competitor_analysis || '',
|
||||
brand_voice: args?.brand_voice || (prefs.post_tone || 'Professional'),
|
||||
compliance_requirements: args?.compliance_requirements || ''
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const safeRespond = React.useCallback((data: any) => {
|
||||
try {
|
||||
if (typeof respond === 'function') respond(data);
|
||||
else console.log('[FB Writer][HITL] respond unavailable; payload:', data);
|
||||
} catch (e) { console.warn('[FB Writer][HITL] respond error', e); }
|
||||
}, [respond]);
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await facebookWriterApi.adCopyGenerate(form as any);
|
||||
const variations = {
|
||||
headline_variations: res?.ad_variations?.headline_variations || res?.data?.ad_variations?.headline_variations || [],
|
||||
primary_text_variations: res?.ad_variations?.primary_text_variations || res?.data?.ad_variations?.primary_text_variations || [],
|
||||
description_variations: res?.ad_variations?.description_variations || res?.data?.ad_variations?.description_variations || [],
|
||||
cta_variations: res?.ad_variations?.cta_variations || res?.data?.ad_variations?.cta_variations || []
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:adVariations', { detail: variations }));
|
||||
const primaryObj = res?.primary_ad_copy || res?.data?.primary_ad_copy;
|
||||
const message = primaryObj?.primary_text || primaryObj?.text || res?.content || res?.data?.content || 'Ad copy generated.';
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: `\n\n${message}` }));
|
||||
logAssistant(message);
|
||||
safeRespond({ success: true, content: message });
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || 'Failed to generate ad copy';
|
||||
setError(`${msg}`);
|
||||
safeRespond({ success: false, message: `${msg}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (k: string, v: any) => setForm((prev: any) => ({ ...prev, [k]: v }));
|
||||
const setNested = (k: keyof typeof form.targeting_options, v: any) => setForm((prev: any) => ({ ...prev, targeting_options: { ...prev.targeting_options, [k]: v } }));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Generate Ad Copy</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<input placeholder="Business type" value={form.business_type} onChange={e => set('business_type', e.target.value)} />
|
||||
<input placeholder="Product/Service" value={form.product_service} onChange={e => set('product_service', e.target.value)} />
|
||||
<input placeholder="Ad objective (e.g., Conversions)" value={form.ad_objective} onChange={e => set('ad_objective', e.target.value)} />
|
||||
<input placeholder="Ad format (e.g., Single image)" value={form.ad_format} onChange={e => set('ad_format', e.target.value)} />
|
||||
<input placeholder="Target audience" value={form.target_audience} onChange={e => set('target_audience', e.target.value)} />
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
<div style={{ fontSize: 12, opacity: 0.9 }}>Targeting</div>
|
||||
<input placeholder="Age group (e.g., 18-24)" value={form.targeting_options.age_group} onChange={e => setNested('age_group', e.target.value)} />
|
||||
<input placeholder="Gender" value={form.targeting_options.gender || ''} onChange={e => setNested('gender', e.target.value)} />
|
||||
<input placeholder="Location" value={form.targeting_options.location || ''} onChange={e => setNested('location', e.target.value)} />
|
||||
<input placeholder="Interests" value={form.targeting_options.interests || ''} onChange={e => setNested('interests', e.target.value)} />
|
||||
</div>
|
||||
<input placeholder="USP" value={form.unique_selling_proposition} onChange={e => set('unique_selling_proposition', e.target.value)} />
|
||||
<input placeholder="Offer details" value={form.offer_details || ''} onChange={e => set('offer_details', e.target.value)} />
|
||||
<input placeholder="Budget range (e.g., $50-200/day)" value={form.budget_range} onChange={e => set('budget_range', e.target.value)} />
|
||||
<input placeholder="Campaign duration" value={form.campaign_duration || ''} onChange={e => set('campaign_duration', e.target.value)} />
|
||||
<input placeholder="Brand voice" value={form.brand_voice || ''} onChange={e => set('brand_voice', e.target.value)} />
|
||||
</div>
|
||||
<button onClick={run} disabled={loading} style={{ marginTop: 8 }}>{loading ? 'Generating…' : 'Generate'}</button>
|
||||
{error && <div style={{ marginTop: 8, color: '#c33', fontSize: 12 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateFacebookAdCopy',
|
||||
description: 'Generate ad copy (primary text) for Facebook ads',
|
||||
parameters: [
|
||||
{ name: 'business_type', type: 'string', required: false },
|
||||
{ name: 'product_service', type: 'string', required: false },
|
||||
{ name: 'ad_objective', type: 'string', required: false },
|
||||
{ name: 'ad_format', type: 'string', required: false },
|
||||
{ name: 'target_audience', type: 'string', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ args, respond }: any) => <AdCopyHITL args={args} respond={respond} />,
|
||||
handler: async (args: any) => {
|
||||
const payload = {
|
||||
business_type: args?.business_type,
|
||||
product_service: args?.product_service || 'Product X',
|
||||
ad_objective: args?.ad_objective || 'Conversions',
|
||||
ad_format: args?.ad_format || 'Single image',
|
||||
target_audience: args?.target_audience,
|
||||
targeting_options: { age_group: '18-24' },
|
||||
unique_selling_proposition: 'Fast, reliable, loved by users',
|
||||
budget_range: '$50-200/day'
|
||||
} as any;
|
||||
const res = await facebookWriterApi.adCopyGenerate(payload);
|
||||
const variations = {
|
||||
headline_variations: res?.ad_variations?.headline_variations || res?.data?.ad_variations?.headline_variations || [],
|
||||
primary_text_variations: res?.ad_variations?.primary_text_variations || res?.data?.ad_variations?.primary_text_variations || [],
|
||||
description_variations: res?.ad_variations?.description_variations || res?.data?.ad_variations?.description_variations || [],
|
||||
cta_variations: res?.ad_variations?.cta_variations || res?.data?.ad_variations?.cta_variations || []
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:adVariations', { detail: variations }));
|
||||
const primaryObj = res?.primary_ad_copy || res?.data?.primary_ad_copy;
|
||||
const message = primaryObj?.primary_text || primaryObj?.text || res?.content || res?.data?.content || 'Ad copy generated.';
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: `\n\n${message}` }));
|
||||
return { success: true, content: message };
|
||||
}
|
||||
});
|
||||
|
||||
// Story generation
|
||||
const VALID_STORY_TYPES = ['Product showcase','Behind the scenes','User testimonial','Event promotion','Tutorial/How-to','Question/Poll','Announcement','Custom'];
|
||||
const VALID_STORY_TONES = ['Casual','Fun','Professional','Inspirational','Educational','Entertaining','Custom'];
|
||||
|
||||
function mapStoryType(t?: string) {
|
||||
const s = (t || '').trim().toLowerCase();
|
||||
const exact = VALID_STORY_TYPES.find(v => v.toLowerCase() === s);
|
||||
if (exact) return exact;
|
||||
if (s.includes('behind')) return 'Behind the scenes';
|
||||
if (s.includes('testi')) return 'User testimonial';
|
||||
if (s.includes('event')) return 'Event promotion';
|
||||
if (s.includes('tutorial') || s.includes('how')) return 'Tutorial/How-to';
|
||||
if (s.includes('poll') || s.includes('question')) return 'Question/Poll';
|
||||
if (s.includes('announce')) return 'Announcement';
|
||||
return 'Product showcase';
|
||||
}
|
||||
function mapStoryTone(t?: string) {
|
||||
const s = (t || '').trim().toLowerCase();
|
||||
const exact = VALID_STORY_TONES.find(v => v.toLowerCase() === s);
|
||||
if (exact) return exact;
|
||||
if (s.includes('fun')) return 'Fun';
|
||||
if (s.includes('inspir')) return 'Inspirational';
|
||||
if (s.includes('educat')) return 'Educational';
|
||||
if (s.includes('entertain')) return 'Entertaining';
|
||||
if (s.includes('pro')) return 'Professional';
|
||||
return 'Casual';
|
||||
}
|
||||
|
||||
const StoryHITL: React.FC<{ args: any; respond?: (data: any) => void }> = ({ args, respond }) => {
|
||||
const prefs = React.useMemo(() => readPrefs(), []);
|
||||
const [form, setForm] = React.useState({
|
||||
business_type: args?.business_type || prefs.business_type || 'SaaS',
|
||||
target_audience: args?.target_audience || prefs.target_audience || 'Marketing managers at SMEs',
|
||||
story_type: mapStoryType(args?.story_type) || 'Product showcase',
|
||||
story_tone: mapStoryTone(args?.story_tone) || 'Casual',
|
||||
include: args?.include || '',
|
||||
avoid: args?.avoid || '',
|
||||
// Advanced options
|
||||
use_hook: true,
|
||||
use_story: true,
|
||||
use_cta: true,
|
||||
use_question: true,
|
||||
use_emoji: true,
|
||||
use_hashtags: true,
|
||||
// Visual options
|
||||
visual_options: {
|
||||
background_type: args?.visual_options?.background_type || 'Solid color',
|
||||
background_image_prompt: args?.visual_options?.background_image_prompt || '',
|
||||
gradient_style: args?.visual_options?.gradient_style || '',
|
||||
text_overlay: args?.visual_options?.text_overlay ?? true,
|
||||
text_style: args?.visual_options?.text_style || '',
|
||||
text_color: args?.visual_options?.text_color || '',
|
||||
text_position: args?.visual_options?.text_position || '',
|
||||
stickers: args?.visual_options?.stickers ?? true,
|
||||
interactive_elements: args?.visual_options?.interactive_elements ?? true,
|
||||
interactive_types: args?.visual_options?.interactive_types || [],
|
||||
call_to_action: args?.visual_options?.call_to_action || ''
|
||||
}
|
||||
});
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const safeRespond = (d: any) => { try { if (typeof respond === 'function') respond(d); } catch {} };
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const payload = {
|
||||
...form,
|
||||
story_type: mapStoryType(form.story_type),
|
||||
story_tone: mapStoryTone(form.story_tone),
|
||||
visual_options: {
|
||||
...form.visual_options,
|
||||
interactive_types: Array.isArray(form.visual_options?.interactive_types)
|
||||
? form.visual_options?.interactive_types
|
||||
: []
|
||||
}
|
||||
} as any;
|
||||
const res = await facebookWriterApi.storyGenerate(payload);
|
||||
const content = res?.content || res?.data?.content;
|
||||
if (content) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: `\n\n${content}` }));
|
||||
logAssistant(content);
|
||||
safeRespond({ success: true, content });
|
||||
} else {
|
||||
safeRespond({ success: true, message: 'Story generated.' });
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || e?.message || 'Failed to generate story';
|
||||
setError(`${msg}`);
|
||||
safeRespond({ success: false, message: `${msg}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const set = (k: string, v: any) => setForm((prev: any) => ({ ...prev, [k]: v }));
|
||||
const setVisual = (k: string, v: any) => setForm((prev: any) => ({ ...prev, visual_options: { ...prev.visual_options, [k]: v } }));
|
||||
const parseInteractive = (s: string): string[] => s.split(',').map(x => x.trim()).filter(Boolean);
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Generate Story</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<input placeholder="Business type" value={form.business_type} onChange={e => set('business_type', e.target.value)} />
|
||||
<input placeholder="Target audience" value={form.target_audience} onChange={e => set('target_audience', e.target.value)} />
|
||||
<input placeholder="Story type (e.g., Product showcase)" value={form.story_type} onChange={e => set('story_type', e.target.value)} />
|
||||
<input placeholder="Tone (e.g., Casual)" value={form.story_tone} onChange={e => set('story_tone', e.target.value)} />
|
||||
<input placeholder="Include" value={form.include} onChange={e => set('include', e.target.value)} />
|
||||
<input placeholder="Avoid" value={form.avoid} onChange={e => set('avoid', e.target.value)} />
|
||||
<div style={{ marginTop: 8, fontWeight: 600 }}>Advanced options</div>
|
||||
<label><input type="checkbox" checked={form.use_hook} onChange={e => set('use_hook', e.target.checked)} /> Hook</label>
|
||||
<label><input type="checkbox" checked={form.use_story} onChange={e => set('use_story', e.target.checked)} /> Narrative</label>
|
||||
<label><input type="checkbox" checked={form.use_cta} onChange={e => set('use_cta', e.target.checked)} /> Include CTA</label>
|
||||
<label><input type="checkbox" checked={form.use_question} onChange={e => set('use_question', e.target.checked)} /> Ask question</label>
|
||||
<label><input type="checkbox" checked={form.use_emoji} onChange={e => set('use_emoji', e.target.checked)} /> Emojis</label>
|
||||
<label><input type="checkbox" checked={form.use_hashtags} onChange={e => set('use_hashtags', e.target.checked)} /> Hashtags</label>
|
||||
|
||||
<div style={{ marginTop: 8, fontWeight: 600 }}>Visual options</div>
|
||||
<input placeholder="Background type (Solid color, Gradient, Image, Video)" value={form.visual_options.background_type} onChange={e => setVisual('background_type', e.target.value)} />
|
||||
<input placeholder="Background image/video prompt (if applicable)" value={form.visual_options.background_image_prompt || ''} onChange={e => setVisual('background_image_prompt', e.target.value)} />
|
||||
<input placeholder="Gradient style" value={form.visual_options.gradient_style || ''} onChange={e => setVisual('gradient_style', e.target.value)} />
|
||||
<label><input type="checkbox" checked={!!form.visual_options.text_overlay} onChange={e => setVisual('text_overlay', e.target.checked)} /> Text overlay</label>
|
||||
<input placeholder="Text style" value={form.visual_options.text_style || ''} onChange={e => setVisual('text_style', e.target.value)} />
|
||||
<input placeholder="Text color" value={form.visual_options.text_color || ''} onChange={e => setVisual('text_color', e.target.value)} />
|
||||
<input placeholder="Text position (e.g., Top-Left)" value={form.visual_options.text_position || ''} onChange={e => setVisual('text_position', e.target.value)} />
|
||||
<label><input type="checkbox" checked={!!form.visual_options.stickers} onChange={e => setVisual('stickers', e.target.checked)} /> Stickers/Emojis</label>
|
||||
<label><input type="checkbox" checked={!!form.visual_options.interactive_elements} onChange={e => setVisual('interactive_elements', e.target.checked)} /> Interactive elements</label>
|
||||
<input placeholder="Interactive types (comma-separated: poll,quiz,slider,countdown)" value={(form.visual_options.interactive_types || []).join(', ')} onChange={e => setVisual('interactive_types', parseInteractive(e.target.value))} />
|
||||
<input placeholder="CTA overlay text" value={form.visual_options.call_to_action || ''} onChange={e => setVisual('call_to_action', e.target.value)} />
|
||||
</div>
|
||||
<button onClick={run} disabled={loading} style={{ marginTop: 8 }}>{loading ? 'Generating…' : 'Generate'}</button>
|
||||
{error && <div style={{ marginTop: 8, color: '#c33', fontSize: 12 }}>{error}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'generateFacebookStory',
|
||||
description: 'Generate a Facebook Story script/copy',
|
||||
parameters: [
|
||||
{ name: 'business_type', type: 'string', required: false },
|
||||
{ name: 'target_audience', type: 'string', required: false },
|
||||
{ name: 'story_type', type: 'string', required: false },
|
||||
{ name: 'story_tone', type: 'string', required: false },
|
||||
{ name: 'include', type: 'string', required: false },
|
||||
{ name: 'avoid', type: 'string', required: false },
|
||||
// Advanced flags
|
||||
{ name: 'use_hook', type: 'boolean', required: false },
|
||||
{ name: 'use_story', type: 'boolean', required: false },
|
||||
{ name: 'use_cta', type: 'boolean', required: false },
|
||||
{ name: 'use_question', type: 'boolean', required: false },
|
||||
{ name: 'use_emoji', type: 'boolean', required: false },
|
||||
{ name: 'use_hashtags', type: 'boolean', required: false },
|
||||
// Visual options
|
||||
{ name: 'visual_options.background_type', type: 'string', required: false },
|
||||
{ name: 'visual_options.background_image_prompt', type: 'string', required: false },
|
||||
{ name: 'visual_options.gradient_style', type: 'string', required: false },
|
||||
{ name: 'visual_options.text_overlay', type: 'boolean', required: false },
|
||||
{ name: 'visual_options.text_style', type: 'string', required: false },
|
||||
{ name: 'visual_options.text_color', type: 'string', required: false },
|
||||
{ name: 'visual_options.text_position', type: 'string', required: false },
|
||||
{ name: 'visual_options.stickers', type: 'boolean', required: false },
|
||||
{ name: 'visual_options.interactive_elements', type: 'boolean', required: false },
|
||||
{ name: 'visual_options.interactive_types', type: 'array', required: false },
|
||||
{ name: 'visual_options.call_to_action', type: 'string', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ args, respond }: any) => <StoryHITL args={args} respond={respond} />,
|
||||
handler: async (args: any) => {
|
||||
const res = await facebookWriterApi.storyGenerate({
|
||||
business_type: args?.business_type,
|
||||
target_audience: args?.target_audience,
|
||||
story_type: mapStoryType(args?.story_type),
|
||||
story_tone: mapStoryTone(args?.story_tone),
|
||||
include: args?.include,
|
||||
avoid: args?.avoid,
|
||||
use_hook: args?.use_hook,
|
||||
use_story: args?.use_story,
|
||||
use_cta: args?.use_cta,
|
||||
use_question: args?.use_question,
|
||||
use_emoji: args?.use_emoji,
|
||||
use_hashtags: args?.use_hashtags,
|
||||
visual_options: {
|
||||
background_type: args?.visual_options?.background_type,
|
||||
background_image_prompt: args?.visual_options?.background_image_prompt,
|
||||
gradient_style: args?.visual_options?.gradient_style,
|
||||
text_overlay: args?.visual_options?.text_overlay,
|
||||
text_style: args?.visual_options?.text_style,
|
||||
text_color: args?.visual_options?.text_color,
|
||||
text_position: args?.visual_options?.text_position,
|
||||
stickers: args?.visual_options?.stickers,
|
||||
interactive_elements: args?.visual_options?.interactive_elements,
|
||||
interactive_types: Array.isArray(args?.visual_options?.interactive_types) ? args?.visual_options?.interactive_types : undefined,
|
||||
call_to_action: args?.visual_options?.call_to_action
|
||||
}
|
||||
});
|
||||
const content = res?.content || res?.data?.content;
|
||||
const images = res?.images_base64 || res?.data?.images_base64;
|
||||
if (content) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:appendDraft', { detail: `\n\n${content}` }));
|
||||
if (Array.isArray(images) && images.length) {
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:storyImages', { detail: images }));
|
||||
}
|
||||
return { success: true, content };
|
||||
}
|
||||
return { success: true, message: 'Story generated.' };
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default RegisterFacebookActions;
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
const EditHITL: React.FC<{ args: any; respond: (data: any) => void }> = ({ args, respond }) => {
|
||||
const [op, setOp] = React.useState<string>(args?.operation || 'Casual');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const apply = async () => {
|
||||
setLoading(true);
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:applyEdit', { detail: { operation: op } }));
|
||||
respond({ success: true, applied: op });
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const ops = ['Casual', 'Professional', 'Upbeat', 'Shorten', 'Lengthen', 'TightenHook', 'AddCTA'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>Edit Draft</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{ops.map(o => (
|
||||
<label key={o} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input type="radio" name="op" value={o} checked={op === o} onChange={() => setOp(o)} />
|
||||
{o}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={apply} disabled={loading}>{loading ? 'Applying…' : 'Apply'}</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RegisterFacebookEditActions: React.FC = () => {
|
||||
useCopilotActionTyped({
|
||||
name: 'editFacebookDraft',
|
||||
description: 'Edit the current Facebook draft (style or structure)',
|
||||
parameters: [
|
||||
{ name: 'operation', type: 'string', description: 'Casual | Professional | Upbeat | Shorten | Lengthen | TightenHook | AddCTA', required: false }
|
||||
],
|
||||
renderAndWaitForResponse: ({ args, respond }: any) => <EditHITL args={args} respond={respond} />,
|
||||
handler: async (args: any) => {
|
||||
const op = args?.operation || 'Casual';
|
||||
window.dispatchEvent(new CustomEvent('fbwriter:applyEdit', { detail: { operation: op } }));
|
||||
return { success: true, applied: op };
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default RegisterFacebookEditActions;
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const TechnicalUI: React.FC<{ args: any; respond: (data: any) => void }> = ({ ar
|
||||
{['full', 'core_web_vitals', 'mobile_friendliness', 'security'].map((s) => (
|
||||
<label key={s} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input type="radio" name="scope" value={s} checked={scope === s} onChange={() => setScope(s)} />
|
||||
{s.replaceAll('_', ' ')}
|
||||
{s.split('_').join(' ')}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
151
frontend/src/services/facebookWriterApi.ts
Normal file
151
frontend/src/services/facebookWriterApi.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
export interface PostGenerateRequest {
|
||||
business_type: string;
|
||||
target_audience: string;
|
||||
post_goal: string; // enum string value as defined by backend
|
||||
post_tone: string; // enum string value as defined by backend
|
||||
include?: string;
|
||||
avoid?: string;
|
||||
media_type?: string; // default "None"
|
||||
advanced_options?: {
|
||||
use_hook?: boolean;
|
||||
use_story?: boolean;
|
||||
use_cta?: boolean;
|
||||
use_question?: boolean;
|
||||
use_emoji?: boolean;
|
||||
use_hashtags?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const facebookWriterApi = {
|
||||
async health(): Promise<any> {
|
||||
const { data } = await apiClient.get('/api/facebook-writer/health');
|
||||
return data;
|
||||
},
|
||||
async tools(): Promise<any> {
|
||||
const { data } = await apiClient.get('/api/facebook-writer/tools');
|
||||
return data;
|
||||
},
|
||||
async postGenerate(payload: PostGenerateRequest): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/facebook-writer/post/generate', payload);
|
||||
return data;
|
||||
},
|
||||
async hashtagsGenerate(payload: { content_topic?: string; draft?: string }): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/facebook-writer/hashtags/generate', payload);
|
||||
return data;
|
||||
},
|
||||
async storyGenerate(payload: {
|
||||
business_type: string;
|
||||
target_audience: string;
|
||||
story_type: string;
|
||||
story_tone: string;
|
||||
include?: string;
|
||||
avoid?: string;
|
||||
// Advanced options parity with backend
|
||||
use_hook?: boolean;
|
||||
use_story?: boolean;
|
||||
use_cta?: boolean;
|
||||
use_question?: boolean;
|
||||
use_emoji?: boolean;
|
||||
use_hashtags?: boolean;
|
||||
visual_options?: {
|
||||
background_type?: string;
|
||||
background_image_prompt?: string;
|
||||
gradient_style?: string;
|
||||
text_overlay?: boolean;
|
||||
text_style?: string;
|
||||
text_color?: string;
|
||||
text_position?: string;
|
||||
stickers?: boolean;
|
||||
interactive_elements?: boolean;
|
||||
interactive_types?: string[];
|
||||
call_to_action?: string;
|
||||
};
|
||||
}): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/facebook-writer/story/generate', payload);
|
||||
return data;
|
||||
},
|
||||
async adCopyGenerate(payload: {
|
||||
business_type: string;
|
||||
product_service: string;
|
||||
ad_objective: string;
|
||||
ad_format: string;
|
||||
target_audience: string;
|
||||
targeting_options: {
|
||||
age_group: string;
|
||||
custom_age?: string;
|
||||
gender?: string;
|
||||
location?: string;
|
||||
interests?: string;
|
||||
behaviors?: string;
|
||||
lookalike_audience?: string;
|
||||
};
|
||||
unique_selling_proposition: string;
|
||||
offer_details?: string;
|
||||
budget_range: string;
|
||||
custom_budget?: string;
|
||||
campaign_duration?: string;
|
||||
competitor_analysis?: string;
|
||||
brand_voice?: string;
|
||||
compliance_requirements?: string;
|
||||
}): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/facebook-writer/ad-copy/generate', payload);
|
||||
return data;
|
||||
}
|
||||
,
|
||||
async reelGenerate(payload: {
|
||||
business_type: string;
|
||||
target_audience: string;
|
||||
reel_type: string;
|
||||
reel_length: string;
|
||||
reel_style: string;
|
||||
topic: string;
|
||||
include?: string;
|
||||
avoid?: string;
|
||||
music_preference?: string;
|
||||
}): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/facebook-writer/reel/generate', payload);
|
||||
return data;
|
||||
}
|
||||
,
|
||||
async carouselGenerate(payload: {
|
||||
business_type: string;
|
||||
target_audience: string;
|
||||
carousel_type: string;
|
||||
topic: string;
|
||||
num_slides?: number;
|
||||
include_cta?: boolean;
|
||||
cta_text?: string;
|
||||
brand_colors?: string;
|
||||
include?: string;
|
||||
avoid?: string;
|
||||
}): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/facebook-writer/carousel/generate', payload);
|
||||
return data;
|
||||
}
|
||||
,
|
||||
async eventGenerate(payload: {
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_format: string;
|
||||
business_type: string;
|
||||
target_audience: string;
|
||||
event_date?: string;
|
||||
event_time?: string;
|
||||
location?: string;
|
||||
duration?: string;
|
||||
key_benefits?: string;
|
||||
speakers?: string;
|
||||
agenda?: string;
|
||||
ticket_info?: string;
|
||||
special_offers?: string;
|
||||
include?: string;
|
||||
avoid?: string;
|
||||
}): Promise<any> {
|
||||
const { data } = await apiClient.post('/api/facebook-writer/event/generate', payload);
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user