ALwrity Facebook Writer CopilotKit Implementation Plan

This commit is contained in:
ajaysi
2025-08-31 18:41:07 +05:30
parent 66c14e158c
commit eb0789321d
11 changed files with 2116 additions and 206 deletions

View File

@@ -30,10 +30,27 @@ class StoryTone(str, Enum):
class StoryVisualOptions(BaseModel): class StoryVisualOptions(BaseModel):
"""Visual options for story.""" """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_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") stickers: bool = Field(default=True, description="Use stickers/emojis")
interactive_elements: bool = Field(default=True, description="Include polls/questions") 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): class FacebookStoryRequest(BaseModel):
@@ -47,12 +64,20 @@ class FacebookStoryRequest(BaseModel):
include: Optional[str] = Field(None, description="Elements to include in the story") include: Optional[str] = Field(None, description="Elements to include in the story")
avoid: Optional[str] = Field(None, description="Elements to avoid 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") 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): class FacebookStoryResponse(BaseModel):
"""Response model for Facebook story generation.""" """Response model for Facebook story generation."""
success: bool = Field(..., description="Whether the generation was successful") success: bool = Field(..., description="Whether the generation was successful")
content: Optional[str] = Field(None, description="Generated story content") 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") visual_suggestions: Optional[List[str]] = Field(None, description="Visual element suggestions")
engagement_tips: Optional[List[str]] = Field(None, description="Engagement optimization tips") engagement_tips: Optional[List[str]] = Field(None, description="Engagement optimization tips")
error: Optional[str] = Field(None, description="Error message if generation failed") error: Optional[str] = Field(None, description="Error message if generation failed")

View File

@@ -2,6 +2,7 @@
from typing import Dict, Any, List from typing import Dict, Any, List
from ..models import * from ..models import *
from ..models.carousel_models import CarouselSlide
from .base_service import FacebookWriterBaseService from .base_service import FacebookWriterBaseService

View File

@@ -3,6 +3,12 @@
from typing import Dict, Any, List from typing import Dict, Any, List
from ..models.story_models import FacebookStoryRequest, FacebookStoryResponse from ..models.story_models import FacebookStoryRequest, FacebookStoryResponse
from .base_service import FacebookWriterBaseService from .base_service import FacebookWriterBaseService
try:
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): class FacebookStoryService(FacebookWriterBaseService):
@@ -38,10 +44,28 @@ class FacebookStoryService(FacebookWriterBaseService):
# Generate visual suggestions and engagement tips # Generate visual suggestions and engagement tips
visual_suggestions = self._generate_visual_suggestions(actual_story_type, request.visual_options) visual_suggestions = self._generate_visual_suggestions(actual_story_type, request.visual_options)
engagement_tips = self._generate_engagement_tips("story") engagement_tips = self._generate_engagement_tips("story")
# Optional: generate one story image (9:16) using Gemini
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( return FacebookStoryResponse(
success=True, success=True,
content=content, content=content,
images_base64=images_base64[:1],
visual_suggestions=visual_suggestions, visual_suggestions=visual_suggestions,
engagement_tips=engagement_tips, engagement_tips=engagement_tips,
metadata={ metadata={
@@ -75,6 +99,28 @@ class FacebookStoryService(FacebookWriterBaseService):
f"Create a {story_type} story" 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""" prompt = f"""
{base_prompt} {base_prompt}
@@ -86,12 +132,20 @@ class FacebookStoryService(FacebookWriterBaseService):
Content Requirements: Content Requirements:
- Include: {request.include or 'N/A'} - Include: {request.include or 'N/A'}
- Avoid: {request.avoid or 'N/A'} - Avoid: {request.avoid or 'N/A'}
{('\n' + advanced_str) if advanced_str else ''}
Visual Options: Visual Options:
- Background Type: {request.visual_options.background_type} - Background Type: {v.background_type}
- Text Overlay: {request.visual_options.text_overlay} - Background Visual Prompt: {v.background_image_prompt or 'N/A'}
- Stickers/Emojis: {request.visual_options.stickers} - Gradient Style: {v.gradient_style or 'N/A'}
- Interactive Elements: {request.visual_options.interactive_elements} - 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: Please create a Facebook Story that:
1. Is optimized for mobile viewing (vertical format) 1. Is optimized for mobile viewing (vertical format)
@@ -137,14 +191,28 @@ class FacebookStoryService(FacebookWriterBaseService):
]) ])
# Add general suggestions based on visual options # 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") suggestions.append("Use bold, readable fonts for text overlays")
if getattr(visual_options, "text_style", None):
if visual_options.stickers: 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") 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") 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 return suggestions

View File

@@ -2,11 +2,11 @@ import os
import sys import sys
import time import time
import datetime import datetime
import streamlit as st import base64
from typing import List, Optional, Tuple
from PIL import Image from PIL import Image
from io import BytesIO from io import BytesIO
from loguru import logger import logging
from tenacity import retry, stop_after_attempt, wait_random_exponential
# Import APIKeyManager # Import APIKeyManager
from ...api_key_manager import APIKeyManager from ...api_key_manager import APIKeyManager
@@ -16,7 +16,9 @@ try:
from google.generativeai import types from google.generativeai import types
except ImportError: except ImportError:
genai = None 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 from .save_image import save_generated_image
@@ -28,9 +30,8 @@ logging.basicConfig(
) )
logger = logging.getLogger('gemini_image_generator') logger = logging.getLogger('gemini_image_generator')
# With image generation in Gemini, your imagination is the limit. # 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. # Follow Google AI best practices for detailed prompts and iterative refinement.
# The more specific you are, the better Gemini can create images that reflect your vision.
# Generate images using Gemini # Generate images using Gemini
# Gemini 2.0 Flash Experimental supports the ability to output text and inline images. # Gemini 2.0 Flash Experimental supports the ability to output text and inline images.
@@ -167,161 +168,131 @@ class AIPromptGenerator:
return ", ".join(prompt_parts) return ", ".join(prompt_parts)
def _ensure_client() -> Optional[object]:
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"): """Create a Gemini client if available and API key is configured."""
"""
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
api_key_manager = APIKeyManager() api_key_manager = APIKeyManager()
api_key = api_key_manager.get_api_key("gemini") api_key = api_key_manager.get_api_key("gemini")
if not api_key or genai is None:
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}")
return None return None
try:
# Enhance the prompt if requested 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: if enhance_prompt and keywords:
prompt_generator = AIPromptGenerator() pg = AIPromptGenerator()
if style == "photorealistic" and focus: enhanced = (
logger.info(f"Generating photorealistic prompt with focus: {focus}") pg.generate_photorealistic_prompt(keywords, focus)
enhanced_prompt = prompt_generator.generate_photorealistic_prompt(keywords, focus) if style == "photorealistic" and focus
else: else pg.generate_prompt(keywords)
logger.info("Generating enhanced prompt") )
enhanced_prompt = prompt_generator.generate_prompt(keywords) prompt = f"{prompt}\n\nEnhanced prompt: {enhanced}"
# Combine the enhanced prompt with the original prompt # Optional hint in-text for aspect ratio; API doesn't take ratio param directly
prompt = f"{prompt}\n\nEnhanced prompt: {enhanced_prompt}"
logger.info(f"Final prompt: '{prompt[:100]}...'")
# Add aspect ratio to the prompt
if aspect_ratio: if aspect_ratio:
prompt += f"\n\nPlease generate the image with {aspect_ratio} aspect ratio." prompt = f"{prompt}\n\nAspect ratio: {aspect_ratio}"
retry_count = 0 client = _ensure_client()
retry_delay = initial_retry_delay if client is None:
logger.warning("Gemini client not available or API key missing")
while retry_count <= max_retries: return []
retry = 0
delay = initial_retry_delay
while retry <= max_retries:
try: try:
client = genai.Client(api_key=api_key)
contents = (prompt)
logger.info("Sending request to Gemini API")
response = client.models.generate_content( response = client.models.generate_content(
model="gemini-2.0-flash-exp-image-generation", model="gemini-2.5-flash-image-preview",
contents=contents, contents=[prompt],
config=types.GenerateContentConfig(
response_modalities=['Text', 'Image']
)
) )
logger.info("Received response from Gemini API") images_b64: List[str] = []
img_name = None
for part in response.candidates[0].content.parts: for part in response.candidates[0].content.parts:
if part.text is not None: if getattr(part, 'inline_data', None) is not None:
logger.info(f"Received text response: '{part.text[:100]}...'") # part.inline_data.data is bytes (base64 decoded by SDK?)
print(part.text) # Standardize to base64 string for API consumers
elif part.inline_data is not None: raw = part.inline_data.data
logger.info("Received image data from Gemini") if isinstance(raw, bytes):
image = Image.open(BytesIO((part.inline_data.data))) images_b64.append(base64.b64encode(raw).decode('utf-8'))
# 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'
else: else:
img_name = f'gemini-native-image-{datetime.datetime.now().strftime("%Y%m%d-%H%M%S")}.png' # Some SDKs may already present base64 str
try: images_b64.append(str(raw))
logger.info(f"Saving image to: {img_name}") return images_b64
image.save(img_name) except Exception as e:
msg = str(e)
# Create a dictionary with the expected format for save_generated_image logger.warning(f"Gemini image gen error: {msg}")
img_response = { if "503" in msg and retry < max_retries:
"artifacts": [ time.sleep(delay)
{ delay *= 2
"base64": base64.b64encode(open(img_name, "rb").read()).decode('utf-8') retry += 1
} continue
] return []
}
# 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
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) - Image editing (text and image to image)
Example prompt: "Edit this image to make it look like a cartoon" 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: while retry_count <= max_retries:
try: try:
client = genai.Client() client = _ensure_client()
if client is None:
return None
text_input = (prompt) text_input = (prompt)
logger.info("Sending request to Gemini API for image editing") 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 edited_img_name = None
for part in response.candidates[0].content.parts: for part in response.candidates[0].content.parts:
if part.text is not None: if getattr(part, 'inline_data', None) is not None:
logger.info(f"Received text response: '{part.text[:100]}...'")
st.write(part.text)
elif part.inline_data is not None:
logger.info("Received edited image data from Gemini") logger.info("Received edited image data from Gemini")
edited_image = Image.open(BytesIO(part.inline_data.data)) edited_image = Image.open(BytesIO(part.inline_data.data))
edited_image.show()
# Save the edited image # Save the edited image
edited_img_name = f'edited-{os.path.basename(image_path)}' 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) save_generated_image(img_response)
except Exception as err: except Exception as err:
logger.error(f"Failed to save edited image: {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}") logger.info(f"Image editing completed. Edited image name: {edited_img_name}")
return edited_img_name return edited_img_name
except Exception as err: except Exception as err:
error_message = str(err) error_message = str(err)
logger.error(f"Error in edit_image: {err}") logger.error(f"Error in edit_image: {err}")
# Retry on transient 503
# Check if this is a 503 UNAVAILABLE error if "503" in error_message and retry_count < max_retries:
if "503 UNAVAILABLE" in error_message and retry_count < max_retries:
retry_count += 1 retry_count += 1
logger.info(f"Model is overloaded. Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})") logger.info(f"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...")
time.sleep(retry_delay) time.sleep(retry_delay)
# Exponential backoff # Exponential backoff
retry_delay *= 2 retry_delay *= 2
else: else:
st.error(f"Error editing image: {err}")
return None return None
# If we've exhausted all retries # If we've exhausted all retries
st.error("The image editing service is currently unavailable. Please try again later.")
return None return None

View 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 (13) 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.

View File

@@ -1,14 +1,209 @@
import React from 'react'; 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 { CopilotSidebar } from '@copilotkit/react-ui';
import { useCopilotReadable, useCopilotAction } from '@copilotkit/react-core'; import { useCopilotReadable, useCopilotAction } from '@copilotkit/react-core';
import '@copilotkit/react-ui/styles.css'; import '@copilotkit/react-ui/styles.css';
import RegisterFacebookActions from './RegisterFacebookActions';
import RegisterFacebookEditActions from './RegisterFacebookEditActions';
const useCopilotActionTyped = useCopilotAction as any; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 FacebookWriter: React.FC = () => {
const [postDraft, setPostDraft] = React.useState<string>(''); const [postDraft, setPostDraft] = React.useState<string>('');
const [notes, setNotes] = 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 // Share current draft and notes with Copilot
useCopilotReadable({ useCopilotReadable({
@@ -31,6 +226,7 @@ const FacebookWriter: React.FC = () => {
], ],
handler: async ({ content }: { content: string }) => { handler: async ({ content }: { content: string }) => {
setPostDraft(content); setPostDraft(content);
setStage('edit');
return { success: true, message: 'Draft updated' }; return { success: true, message: 'Draft updated' };
} }
}); });
@@ -44,36 +240,115 @@ const FacebookWriter: React.FC = () => {
], ],
handler: async ({ content }: { content: string }) => { handler: async ({ content }: { content: string }) => {
setPostDraft(prev => (prev ? `${prev}\n\n${content}` : content)); setPostDraft(prev => (prev ? `${prev}\n\n${content}` : content));
setStage('edit');
return { success: true, message: 'Text appended' }; return { success: true, message: 'Text appended' };
} }
}); });
const suggestions = [ const startSuggestions = [
{ title: '🎉 Launch teaser', message: 'Write a short Facebook post announcing our new feature launch' }, { title: '🎉 Launch teaser', message: 'Use tool generateFacebookPost to 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: '💡 Benefit-first', message: 'Use tool generateFacebookPost to draft a benefit-first Facebook post with a strong CTA.' },
{ title: '🔁 Variations', message: 'Generate 3 alternative versions of this post to A/B test' }, { title: '🏷️ Hashtags', message: 'Use tool generateFacebookHashtags to suggest 5 relevant hashtags for this post.' },
{ title: '🏷️ Hashtags', message: '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 ( return (
<CopilotSidebar <CopilotSidebar
className="alwrity-copilot-sidebar" className="alwrity-copilot-sidebar"
labels={{ labels={{
title: 'ALwrity • Facebook Writer', 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} 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 }}> <RegisterFacebookActions />
<Container maxWidth="md"> <RegisterFacebookEditActions />
<Typography variant="h4" sx={{ color: 'white', fontWeight: 800, mb: 2 }}> <Box
Facebook Writer (Preview) sx={{
</Typography> 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 }}> <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> </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 }}> <Typography variant="subtitle2" sx={{ color: 'rgba(255,255,255,0.9)', mb: 1 }}>
Context/Notes (optional) Context/Notes (optional)
</Typography> </Typography>
@@ -87,32 +362,198 @@ const FacebookWriter: React.FC = () => {
sx={{ sx={{
mb: 2, mb: 2,
'& .MuiInputBase-root': { color: 'white' }, '& .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)' }
/>
<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)' }
}} }}
/> />
</Paper> </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> </Container>
</Box> </Box>
</CopilotSidebar> </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; export default FacebookWriter;

View File

@@ -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;

View File

@@ -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;

View File

@@ -40,7 +40,7 @@ const TechnicalUI: React.FC<{ args: any; respond: (data: any) => void }> = ({ ar
{['full', 'core_web_vitals', 'mobile_friendliness', 'security'].map((s) => ( {['full', 'core_web_vitals', 'mobile_friendliness', 'security'].map((s) => (
<label key={s} style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <label key={s} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input type="radio" name="scope" value={s} checked={scope === s} onChange={() => setScope(s)} /> <input type="radio" name="scope" value={s} checked={scope === s} onChange={() => setScope(s)} />
{s.replaceAll('_', ' ')} {s.split('_').join(' ')}
</label> </label>
))} ))}
</div> </div>

View 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;
}
};