From eb0789321d2397679b072f29fe0f217d6ca61eec Mon Sep 17 00:00:00 2001 From: ajaysi Date: Sun, 31 Aug 2025 18:41:07 +0530 Subject: [PATCH] ALwrity Facebook Writer CopilotKit Implementation Plan --- .../facebook_writer/models/story_models.py | 27 +- .../services/remaining_services.py | 1 + .../facebook_writer/services/story_service.py | 84 +- .../gen_gemini_images.py | 293 +++--- .../FACEBOOK_WRITER_MIGRATION_SUMMARY.md | 0 ...book_Writer_CopilotKit_Integration_Plan.md | 215 ++++ .../FacebookWriter/FacebookWriter.tsx | 503 ++++++++- .../RegisterFacebookActions.tsx | 991 ++++++++++++++++++ .../RegisterFacebookEditActions.tsx | 55 + .../actions/RegisterTechnical.tsx | 2 +- frontend/src/services/facebookWriterApi.ts | 151 +++ 11 files changed, 2116 insertions(+), 206 deletions(-) rename FACEBOOK_WRITER_MIGRATION_SUMMARY.md => docs/FACEBOOK_WRITER_MIGRATION_SUMMARY.md (100%) create mode 100644 docs/Facebook_Writer_CopilotKit_Integration_Plan.md create mode 100644 frontend/src/components/FacebookWriter/RegisterFacebookActions.tsx create mode 100644 frontend/src/components/FacebookWriter/RegisterFacebookEditActions.tsx create mode 100644 frontend/src/services/facebookWriterApi.ts diff --git a/backend/api/facebook_writer/models/story_models.py b/backend/api/facebook_writer/models/story_models.py index 048fc6b5..f2bdbd96 100644 --- a/backend/api/facebook_writer/models/story_models.py +++ b/backend/api/facebook_writer/models/story_models.py @@ -30,10 +30,27 @@ class StoryTone(str, Enum): class StoryVisualOptions(BaseModel): """Visual options for story.""" - background_type: str = Field(default="Solid color", description="Background type") + # Background layer + background_type: str = Field(default="Solid color", description="Background type (Solid color, Gradient, Image, Video)") + background_image_prompt: Optional[str] = Field(None, description="If background_type is Image/Video, describe desired visual") + gradient_style: Optional[str] = Field(None, description="Gradient style if gradient background is chosen") + + # Text overlay styling text_overlay: bool = Field(default=True, description="Include text overlay") + text_style: Optional[str] = Field(None, description="Headline/Subtext style, e.g., Bold, Minimal, Handwritten") + text_color: Optional[str] = Field(None, description="Preferred text color or palette") + text_position: Optional[str] = Field(None, description="Top/Center/Bottom; Left/Center/Right") + + # Embellishments and interactivity stickers: bool = Field(default=True, description="Use stickers/emojis") interactive_elements: bool = Field(default=True, description="Include polls/questions") + interactive_types: Optional[List[str]] = Field( + default=None, + description="List of interactive types like ['poll','quiz','slider','countdown']" + ) + + # CTA overlay + call_to_action: Optional[str] = Field(None, description="Optional CTA copy to place on story") class FacebookStoryRequest(BaseModel): @@ -47,12 +64,20 @@ class FacebookStoryRequest(BaseModel): include: Optional[str] = Field(None, description="Elements to include in the story") avoid: Optional[str] = Field(None, description="Elements to avoid in the story") visual_options: StoryVisualOptions = Field(default_factory=StoryVisualOptions, description="Visual customization options") + # Advanced text generation options (parity with original Streamlit module) + use_hook: bool = Field(default=True, description="Start with a hook to grab attention") + use_story: bool = Field(default=True, description="Use a short narrative arc") + use_cta: bool = Field(default=True, description="Include a call to action") + use_question: bool = Field(default=True, description="Ask a question to spur interaction") + use_emoji: bool = Field(default=True, description="Use emojis where appropriate") + use_hashtags: bool = Field(default=True, description="Include relevant hashtags in copy") class FacebookStoryResponse(BaseModel): """Response model for Facebook story generation.""" success: bool = Field(..., description="Whether the generation was successful") content: Optional[str] = Field(None, description="Generated story content") + images_base64: Optional[List[str]] = Field(None, description="List of base64-encoded story images (PNG)") visual_suggestions: Optional[List[str]] = Field(None, description="Visual element suggestions") engagement_tips: Optional[List[str]] = Field(None, description="Engagement optimization tips") error: Optional[str] = Field(None, description="Error message if generation failed") diff --git a/backend/api/facebook_writer/services/remaining_services.py b/backend/api/facebook_writer/services/remaining_services.py index 38387cc4..2f7933af 100644 --- a/backend/api/facebook_writer/services/remaining_services.py +++ b/backend/api/facebook_writer/services/remaining_services.py @@ -2,6 +2,7 @@ from typing import Dict, Any, List from ..models import * +from ..models.carousel_models import CarouselSlide from .base_service import FacebookWriterBaseService diff --git a/backend/api/facebook_writer/services/story_service.py b/backend/api/facebook_writer/services/story_service.py index e5905821..41ccbd27 100644 --- a/backend/api/facebook_writer/services/story_service.py +++ b/backend/api/facebook_writer/services/story_service.py @@ -3,6 +3,12 @@ from typing import Dict, Any, List from ..models.story_models import FacebookStoryRequest, FacebookStoryResponse from .base_service import FacebookWriterBaseService +try: + from ...services.llm_providers.text_to_image_generation.gen_gemini_images import ( + generate_gemini_images_base64, + ) +except Exception: + generate_gemini_images_base64 = None # type: ignore class FacebookStoryService(FacebookWriterBaseService): @@ -38,10 +44,28 @@ class FacebookStoryService(FacebookWriterBaseService): # Generate visual suggestions and engagement tips visual_suggestions = self._generate_visual_suggestions(actual_story_type, request.visual_options) engagement_tips = self._generate_engagement_tips("story") + # Optional: generate one story image (9:16) using Gemini + images_base64: List[str] = [] + try: + if generate_gemini_images_base64 is not None: + img_prompt = request.visual_options.background_image_prompt or ( + f"Facebook story background for {request.business_type}. " + f"Style: {actual_tone}. Type: {actual_story_type}. Vertical mobile 9:16, high contrast, legible overlay space." + ) + images_base64 = generate_gemini_images_base64( + img_prompt, + enhance_prompt=False, + aspect_ratio="9:16", + max_retries=2, + initial_retry_delay=1.0, + ) or [] + except Exception: + images_base64 = [] return FacebookStoryResponse( success=True, content=content, + images_base64=images_base64[:1], visual_suggestions=visual_suggestions, engagement_tips=engagement_tips, metadata={ @@ -75,6 +99,28 @@ class FacebookStoryService(FacebookWriterBaseService): f"Create a {story_type} story" ) + # Advanced writing flags + advanced_lines = [] + if getattr(request, "use_hook", True): + advanced_lines.append("- Start with a compelling hook in the first line") + if getattr(request, "use_story", True): + advanced_lines.append("- Use a mini narrative with a clear flow") + if getattr(request, "use_cta", True): + cta_text = request.visual_options.call_to_action or "Add a clear call-to-action" + advanced_lines.append(f"- Include a CTA: {cta_text}") + if getattr(request, "use_question", True): + advanced_lines.append("- Ask a question to prompt replies or taps") + if getattr(request, "use_emoji", True): + advanced_lines.append("- Use a few relevant emojis for tone and scannability") + if getattr(request, "use_hashtags", True): + advanced_lines.append("- Include 1-3 relevant hashtags if appropriate") + + advanced_str = "\n".join(advanced_lines) + + # Visual details + v = request.visual_options + interactive_types_str = ", ".join(v.interactive_types) if v.interactive_types else "None specified" + prompt = f""" {base_prompt} @@ -86,12 +132,20 @@ class FacebookStoryService(FacebookWriterBaseService): Content Requirements: - Include: {request.include or 'N/A'} - Avoid: {request.avoid or 'N/A'} + {('\n' + advanced_str) if advanced_str else ''} Visual Options: - - Background Type: {request.visual_options.background_type} - - Text Overlay: {request.visual_options.text_overlay} - - Stickers/Emojis: {request.visual_options.stickers} - - Interactive Elements: {request.visual_options.interactive_elements} + - Background Type: {v.background_type} + - Background Visual Prompt: {v.background_image_prompt or 'N/A'} + - Gradient Style: {v.gradient_style or 'N/A'} + - Text Overlay: {v.text_overlay} + - Text Style: {v.text_style or 'N/A'} + - Text Color: {v.text_color or 'N/A'} + - Text Position: {v.text_position or 'N/A'} + - Stickers/Emojis: {v.stickers} + - Interactive Elements: {v.interactive_elements} + - Interactive Types: {interactive_types_str} + - Call To Action: {v.call_to_action or 'N/A'} Please create a Facebook Story that: 1. Is optimized for mobile viewing (vertical format) @@ -137,14 +191,28 @@ class FacebookStoryService(FacebookWriterBaseService): ]) # Add general suggestions based on visual options - if visual_options.text_overlay: + if getattr(visual_options, "text_overlay", True): suggestions.append("Use bold, readable fonts for text overlays") - - if visual_options.stickers: + if getattr(visual_options, "text_style", None): + suggestions.append(f"Match text style to tone: {visual_options.text_style}") + if getattr(visual_options, "text_color", None): + suggestions.append(f"Ensure sufficient contrast with text color: {visual_options.text_color}") + if getattr(visual_options, "text_position", None): + suggestions.append(f"Place text at {visual_options.text_position} to avoid occluding subject") + + if getattr(visual_options, "stickers", True): suggestions.append("Add relevant emojis and stickers to increase engagement") - if visual_options.interactive_elements: + if getattr(visual_options, "interactive_elements", True): suggestions.append("Include polls, questions, or swipe-up actions") + if getattr(visual_options, "interactive_types", None): + suggestions.append(f"Try interactive types: {', '.join(visual_options.interactive_types)}") + + if getattr(visual_options, "background_type", None) in {"Image", "Video"} and getattr(visual_options, "background_image_prompt", None): + suggestions.append("Source visuals based on background prompt for consistency") + + if getattr(visual_options, "call_to_action", None): + suggestions.append(f"Overlay CTA copy near focal point: {visual_options.call_to_action}") return suggestions diff --git a/backend/services/llm_providers/text_to_image_generation/gen_gemini_images.py b/backend/services/llm_providers/text_to_image_generation/gen_gemini_images.py index 5e2a9b86..ddac3de9 100644 --- a/backend/services/llm_providers/text_to_image_generation/gen_gemini_images.py +++ b/backend/services/llm_providers/text_to_image_generation/gen_gemini_images.py @@ -2,11 +2,11 @@ import os import sys import time import datetime -import streamlit as st +import base64 +from typing import List, Optional, Tuple from PIL import Image from io import BytesIO -from loguru import logger -from tenacity import retry, stop_after_attempt, wait_random_exponential +import logging # Import APIKeyManager from ...api_key_manager import APIKeyManager @@ -16,7 +16,9 @@ try: from google.generativeai import types except ImportError: genai = None - logger.warning("Google genai library not available. Install with: pip install google-generativeai") + logging.getLogger('gemini_image_generator').warning( + "Google genai library not available. Install with: pip install google-generativeai" + ) from .save_image import save_generated_image @@ -28,9 +30,8 @@ logging.basicConfig( ) logger = logging.getLogger('gemini_image_generator') -# With image generation in Gemini, your imagination is the limit. -# If what you see doesn't quite match what you had in mind, try adding more details to the prompt. -# The more specific you are, the better Gemini can create images that reflect your vision. +# With image generation in Gemini, your imagination is the limit. +# Follow Google AI best practices for detailed prompts and iterative refinement. # Generate images using Gemini # Gemini 2.0 Flash Experimental supports the ability to output text and inline images. @@ -167,161 +168,131 @@ class AIPromptGenerator: return ", ".join(prompt_parts) - -def generate_gemini_image(prompt, keywords=None, style=None, focus=None, enhance_prompt=True, max_retries=3, initial_retry_delay=2, aspect_ratio="16:9"): - """ - Generate an image using Gemini's image generation capabilities. - - Args: - prompt (str): The text prompt for image generation - keywords (list, optional): Keywords to enhance the prompt - style (str, optional): Style of the image (photorealistic, artistic, etc.) - focus (str, optional): Focus area for photorealistic images - enhance_prompt (bool, optional): Whether to enhance the prompt with AI - max_retries (int, optional): Maximum number of retry attempts - initial_retry_delay (int, optional): Initial delay between retries - aspect_ratio (str, optional): Aspect ratio for the generated image - - Returns: - str: The path to the generated image. - """ - logger.info(f"Generating image with prompt: '{prompt[:100]}...'") - - # Use APIKeyManager instead of direct environment variable access +def _ensure_client() -> Optional[object]: + """Create a Gemini client if available and API key is configured.""" api_key_manager = APIKeyManager() api_key = api_key_manager.get_api_key("gemini") - - if not api_key: - error_msg = "Gemini API key not found. Please configure it in the onboarding process." - logger.error(error_msg) - st.error(f"🔑 {error_msg}") + if not api_key or genai is None: return None - - # Enhance the prompt if requested + try: + return genai.Client(api_key=api_key) + except Exception: + return None + + +def generate_gemini_images_base64( + prompt: str, + *, + keywords: Optional[list] = None, + style: Optional[str] = None, + focus: Optional[str] = None, + enhance_prompt: bool = True, + aspect_ratio: str = "9:16", + max_retries: int = 2, + initial_retry_delay: float = 1.0, +) -> List[str]: + """ + Return list of base64 PNG images generated from a prompt. + + Implements best practices per Gemini docs: send text prompt, parse inline image parts, + and return base64 data suitable for API responses. No Streamlit, no printing. + + Docs: https://ai.google.dev/gemini-api/docs/image-generation + """ + logger = logging.getLogger('gemini_image_generator') + logger.info("Generating image (base64) with Gemini") + if enhance_prompt and keywords: - prompt_generator = AIPromptGenerator() - if style == "photorealistic" and focus: - logger.info(f"Generating photorealistic prompt with focus: {focus}") - enhanced_prompt = prompt_generator.generate_photorealistic_prompt(keywords, focus) - else: - logger.info("Generating enhanced prompt") - enhanced_prompt = prompt_generator.generate_prompt(keywords) - - # Combine the enhanced prompt with the original prompt - prompt = f"{prompt}\n\nEnhanced prompt: {enhanced_prompt}" - logger.info(f"Final prompt: '{prompt[:100]}...'") - - # Add aspect ratio to the prompt + pg = AIPromptGenerator() + enhanced = ( + pg.generate_photorealistic_prompt(keywords, focus) + if style == "photorealistic" and focus + else pg.generate_prompt(keywords) + ) + prompt = f"{prompt}\n\nEnhanced prompt: {enhanced}" + + # Optional hint in-text for aspect ratio; API doesn't take ratio param directly if aspect_ratio: - prompt += f"\n\nPlease generate the image with {aspect_ratio} aspect ratio." - - retry_count = 0 - retry_delay = initial_retry_delay - - while retry_count <= max_retries: + prompt = f"{prompt}\n\nAspect ratio: {aspect_ratio}" + + client = _ensure_client() + if client is None: + logger.warning("Gemini client not available or API key missing") + return [] + + retry = 0 + delay = initial_retry_delay + while retry <= max_retries: try: - client = genai.Client(api_key=api_key) - contents = (prompt) - - logger.info("Sending request to Gemini API") response = client.models.generate_content( - model="gemini-2.0-flash-exp-image-generation", - contents=contents, - config=types.GenerateContentConfig( - response_modalities=['Text', 'Image'] - ) + model="gemini-2.5-flash-image-preview", + contents=[prompt], ) - logger.info("Received response from Gemini API") - - img_name = None + images_b64: List[str] = [] for part in response.candidates[0].content.parts: - if part.text is not None: - logger.info(f"Received text response: '{part.text[:100]}...'") - print(part.text) - elif part.inline_data is not None: - logger.info("Received image data from Gemini") - image = Image.open(BytesIO((part.inline_data.data))) - - # Resize image to match aspect ratio if needed - if aspect_ratio: - current_width, current_height = image.size - target_width = current_width - target_height = current_height - - # Calculate target dimensions based on aspect ratio - if aspect_ratio == "16:9": - target_height = int(current_width * 9/16) - elif aspect_ratio == "9:16": - target_width = int(current_height * 9/16) - elif aspect_ratio == "4:3": - target_height = int(current_width * 3/4) - elif aspect_ratio == "3:4": - target_width = int(current_height * 3/4) - elif aspect_ratio == "1:1": - target_size = min(current_width, current_height) - target_width = target_size - target_height = target_size - - logger.info(f"Resizing image from {current_width}x{current_height} to {target_width}x{target_height}") - - # Create a new image with the target dimensions - resized_image = Image.new('RGB', (target_width, target_height), (255, 255, 255)) - - # Calculate position to paste the original image - paste_x = (target_width - current_width) // 2 - paste_y = (target_height - current_height) // 2 - - # Paste the original image onto the new canvas - resized_image.paste(image, (paste_x, paste_y)) - image = resized_image - - if part.text is not None: - img_name = f'{part.text}-gemini-native-image.png' + if getattr(part, 'inline_data', None) is not None: + # part.inline_data.data is bytes (base64 decoded by SDK?) + # Standardize to base64 string for API consumers + raw = part.inline_data.data + if isinstance(raw, bytes): + images_b64.append(base64.b64encode(raw).decode('utf-8')) else: - img_name = f'gemini-native-image-{datetime.datetime.now().strftime("%Y%m%d-%H%M%S")}.png' - try: - logger.info(f"Saving image to: {img_name}") - image.save(img_name) - - # Create a dictionary with the expected format for save_generated_image - img_response = { - "artifacts": [ - { - "base64": base64.b64encode(open(img_name, "rb").read()).decode('utf-8') - } - ] - } - - # Call save_generated_image with the correct format - save_generated_image(img_response) - except Exception as err: - logger.error(f"Failed to save image: {err}") - st.error(f"Failed to save image: {err}") - - logger.info(f"Image generation completed. Image name: {img_name}") - return img_name - except Exception as err: - error_message = str(err) - logger.error(f"Error in generate_gemini_image: {err}") - - # Check if this is a 503 UNAVAILABLE error - if "503 UNAVAILABLE" in error_message and retry_count < max_retries: - retry_count += 1 - logger.info(f"Model is overloaded. Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})") - st.warning(f"The image generation service is currently busy. Retrying in {retry_delay} seconds...") - time.sleep(retry_delay) - # Exponential backoff - retry_delay *= 2 - else: - st.error(f"Error generating image: {err}") - return None - - # If we've exhausted all retries - st.error("The image generation service is currently unavailable. Please try again later.") - return None + # Some SDKs may already present base64 str + images_b64.append(str(raw)) + return images_b64 + except Exception as e: + msg = str(e) + logger.warning(f"Gemini image gen error: {msg}") + if "503" in msg and retry < max_retries: + time.sleep(delay) + delay *= 2 + retry += 1 + continue + return [] -def edit_image(image_path, prompt, max_retries=3, initial_retry_delay=2): +def generate_gemini_image( + prompt, + keywords=None, + style=None, + focus=None, + enhance_prompt=True, + max_retries=2, + initial_retry_delay=1.0, + aspect_ratio="9:16", +): + """ + Backward-compatible wrapper that generates a single image file on disk and returns path. + Prefer generate_gemini_images_base64 in new code paths. + """ + logger = logging.getLogger('gemini_image_generator') + images = generate_gemini_images_base64( + prompt, + keywords=keywords, + style=style, + focus=focus, + enhance_prompt=enhance_prompt, + aspect_ratio=aspect_ratio, + max_retries=max_retries, + initial_retry_delay=initial_retry_delay, + ) + if not images: + return None + # Persist first image to file for legacy callers + img_b64 = images[0] + img_bytes = base64.b64decode(img_b64) + img = Image.open(BytesIO(img_bytes)) + out_name = f'gemini-native-image-{datetime.datetime.now().strftime("%Y%m%d-%H%M%S")}.png' + try: + img.save(out_name) + # Also call save_generated_image to reuse existing pipeline + save_generated_image({"artifacts": [{"base64": img_b64}]}) + return out_name + except Exception: + return None + + +def edit_image(image_path, prompt, max_retries=2, initial_retry_delay=1.0): """ - Image editing (text and image to image) Example prompt: "Edit this image to make it look like a cartoon" @@ -352,7 +323,9 @@ def edit_image(image_path, prompt, max_retries=3, initial_retry_delay=2): while retry_count <= max_retries: try: - client = genai.Client() + client = _ensure_client() + if client is None: + return None text_input = (prompt) logger.info("Sending request to Gemini API for image editing") @@ -367,13 +340,9 @@ def edit_image(image_path, prompt, max_retries=3, initial_retry_delay=2): edited_img_name = None for part in response.candidates[0].content.parts: - if part.text is not None: - logger.info(f"Received text response: '{part.text[:100]}...'") - st.write(part.text) - elif part.inline_data is not None: + if getattr(part, 'inline_data', None) is not None: logger.info("Received edited image data from Gemini") edited_image = Image.open(BytesIO(part.inline_data.data)) - edited_image.show() # Save the edited image edited_img_name = f'edited-{os.path.basename(image_path)}' @@ -394,28 +363,22 @@ def edit_image(image_path, prompt, max_retries=3, initial_retry_delay=2): save_generated_image(img_response) except Exception as err: logger.error(f"Failed to save edited image: {err}") - st.error(f"Failed to save edited image: {err}") logger.info(f"Image editing completed. Edited image name: {edited_img_name}") return edited_img_name except Exception as err: error_message = str(err) logger.error(f"Error in edit_image: {err}") - - # Check if this is a 503 UNAVAILABLE error - if "503 UNAVAILABLE" in error_message and retry_count < max_retries: + # Retry on transient 503 + if "503" in error_message and retry_count < max_retries: retry_count += 1 - logger.info(f"Model is overloaded. Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})") - st.warning(f"The image editing service is currently busy. Retrying in {retry_delay} seconds...") + logger.info(f"Retrying in {retry_delay} seconds (attempt {retry_count}/{max_retries})") time.sleep(retry_delay) # Exponential backoff retry_delay *= 2 else: - st.error(f"Error editing image: {err}") return None - # If we've exhausted all retries - st.error("The image editing service is currently unavailable. Please try again later.") return None diff --git a/FACEBOOK_WRITER_MIGRATION_SUMMARY.md b/docs/FACEBOOK_WRITER_MIGRATION_SUMMARY.md similarity index 100% rename from FACEBOOK_WRITER_MIGRATION_SUMMARY.md rename to docs/FACEBOOK_WRITER_MIGRATION_SUMMARY.md diff --git a/docs/Facebook_Writer_CopilotKit_Integration_Plan.md b/docs/Facebook_Writer_CopilotKit_Integration_Plan.md new file mode 100644 index 00000000..259c5654 --- /dev/null +++ b/docs/Facebook_Writer_CopilotKit_Integration_Plan.md @@ -0,0 +1,215 @@ + +# Facebook Writer + CopilotKit: Feature Set and Implementation Plan + +## 0) Current Implementation Status (Updated) +- Core page and routing: `/facebook-writer` implemented with `CopilotSidebar` and scoped styling. +- Readables: `postDraft`, `notes` exposed to Copilot; preferences summarized into system message. +- Predictive state updates: live typing with progressive diff preview (green adds, red strikethrough deletes), then auto-commit. +- Edit actions: `editFacebookDraft` (Casual, Professional, Upbeat, Shorten, Lengthen, TightenHook, AddCTA) with HITL micro-form; applies live preview via custom events. +- Generation actions: `generateFacebookPost`, `generateFacebookHashtags`, `generateFacebookAdCopy` integrated with FastAPI endpoints; results synced to editor via window events. +- Facebook Story: `generateFacebookStory` added with advanced and visual options (tone, include/avoid, CTA, stickers, text overlay, interactive types, etc.). Backend returns `content` plus one 9:16 image (`images_base64[0]`) generated via Gemini and the UI renders a Story Images panel. +- Image generation module refactor: `gen_gemini_images.py` made backend-safe (removed Streamlit), added base64-first API, light retries, aligned with Gemini best practices. +- Input robustness: frontend normalization/mapping to backend enum strings (prevents 422); friendly HITL validation. +- Suggestions: progressive suggestions switch from “create” to “edit” when draft exists; stage-aware heuristics in place. +- Chat memory and preferences: localStorage persistence of last 50 messages; recent conversation and saved preferences injected into `makeSystemMessage`; “Clear chat memory” button. +- Confirm/Reject: explicit controls for predictive edits (Confirm changes / Discard) implemented. +- Observability: Facebook writer requests flow through existing middleware; compact header control already live app-wide. Route-specific counters verification pending (planned below). + +Gaps / Remaining: +- Context-aware suggestions need further refinement (e.g., based on draft length, tone, goal, time of day). +- Tests for actions/handlers, reducer-like state transitions, and suggestion sets. +- Observability counters and tags for `/api/facebook-writer/*` endpoints. +- Backend session persistence (server-side conversation memory) for cross-device continuity (optional, phase-able). +- Image generation controls (toggle, retries, error UX), caching, and cost guardrails. + + +## 1) Goals +- Provide a specialized Facebook Writer surface powered by CopilotKit. +- Deliver intelligent, HITL (human-in-the-loop) workflows using Facebook Writer PR endpoints. +- Reuse CopilotKit best practices (predictive state updates) as demonstrated in the example demo. +- Ensure observability via existing middleware so system status appears in the main header control. + +Reference demo: https://demo-viewer-five.vercel.app/feature/predictive_state_updates + +--- + +## 2) Feature Set + +### A. Core Copilot sidebar (Facebook Writer page) +- Personalized title and greeting (brand/tenant aware when available). +- Progressive suggestion groups: + - Social content + - Ads & campaigns + - Engagement & optimization +- Always-on context-aware quick actions based on draft state (empty vs non-empty vs long draft). + +### B. Predictive state + collaborative editing +- Readables + - draft: current post text + - notes/context: campaign intent, audience, key points + - preferences: tone, objective, hashtags on/off (persisted locally; summarized to system message) +- Actions + - updateFacebookPostDraft(content) + - appendToFacebookPostDraft(content) + - editFacebookDraft(operation) + - summarizeDraft() (planned) + - rewriteDraft(style|objective) (planned) + +### C. PR endpoint coverage (initial, minimal) +- POST /api/facebook-writer/post/generate (implemented) +- POST /api/facebook-writer/hashtags/generate (implemented) +- POST /api/facebook-writer/ad-copy/generate (implemented) +- POST /api/facebook-writer/story/generate (implemented) +- GET /api/facebook-writer/tools (implemented) +- GET /api/facebook-writer/health (implemented) + +Next endpoints (planned): +- Subsequent additions: reel/carousel/event/group/page-about + +### D. HITL micro-forms +- Minimal modals inline in chat for: + - Objective (awareness, engagement, traffic, launch) + - Tone (professional, casual, upbeat, custom) + - Audience (free text) + - Include/avoid (free text) + - Hashtags on/off + +### E. Intelligent suggestions +- Empty draft → “Create launch teaser”, “Benefit-first post”, “3 variants to A/B test” +- Non-empty draft → “Tighten hook”, “Add CTA”, “Rewrite for professional tone”, “Generate hashtags” (live) +- Long draft → “Summarize to 120-150 chars intro”, “Split into carousel captions” (future) + +### F. Observability and status +- Ensure facebook endpoints counted in monitoring so the compact header “System • STATUS” reflects their activity. + +--- + +## 3) Frontend Implementation Plan + +### 3.1 Route and page +- Route: `/facebook-writer` +- Component: `frontend/src/components/FacebookWriter/FacebookWriter.tsx` + - CopilotSidebar (scoped styling class) + - Textareas for notes and postDraft + - Readables: notes, postDraft + - Actions: updateFacebookPostDraft, appendToFacebookPostDraft + +### 3.2 API client +- File: `frontend/src/services/facebookWriterApi.ts` + - postGenerate(req) + - adCopyGenerate(req) + - hashtagsGenerate(req) + - storyGenerate(req) [advanced + visual options] + - tools(), health() +- Types aligned with PR models (enum value strings must match server models). + +### 3.3 Copilot actions (HITL + server calls) +- File: `frontend/src/components/FacebookWriter/RegisterFacebookActions.tsx` + - Action: generateFacebookPost + - renderAndWaitForResponse → prompt for goal, tone, audience, include/avoid, hashtags + - Call api.postGenerate → update draft + - Action: generateHashtags + - renderAndWaitForResponse → topic or use draft + - Call api.hashtagsGenerate → append to draft + - Action: generateAdCopy (implemented) + - renderAndWaitForResponse → prompt for business_type, product/service, objective, format, audience, targeting basics, USP, budget + - Call api.adCopyGenerate → append primary text to draft; keep variations for UI + - Action: generateFacebookStory (implemented) + - renderAndWaitForResponse → advanced (hooks, CTA, etc.) and visual options (background type/prompt, overlay, interactive types) + - Call api.storyGenerate → append story content; dispatch `fbwriter:storyImages` to render returned image(s) +- Helper: custom window events keep editor as single source of truth. + +### 3.4 Suggestions and system message +- Suggestions computed from draft length, last action result, and notes presence. +- System message includes short brand tone guidance when available. + +### 3.5 Demo parity (predictive state updates) +- Expose two local actions for state updates: + - updateFacebookPostDraft + - appendToFacebookPostDraft +- Ensure Copilot can call those without round-tripping to backend for quick edits. +- Confirm/Reject step before committing predictive edits (implemented) + +--- + +## 4) Backend Integration Plan + +### 4.1 Use PR structure +- Routers: `backend/api/facebook_writer/routers/facebook_router.py`. +- Services: `backend/api/facebook_writer/services/*`. +- Models: `backend/api/facebook_writer/models/*`. + +### 4.2 Minimal requests for post.generate +- Map HITL selections to `FacebookPostRequest` fields: + - post_goal: enum string value (e.g., “Build brand awareness”) + - post_tone: enum string value (e.g., “Professional”) + - media_type: “None” (default) + - advanced_options: from toggles +- Handle 422 by ensuring exact enum text. + +### 4.3 Monitoring +- No changes required if middleware already counts routes; confirm they appear in status. + +--- + +## 5) UX details +- Sidebar personalized title: “ALwrity • Facebook Writer”. +- Glassomorphic style aligned with SEO assistant. +- Accessibility: focus-visible rings, reduced-motion respect. +- Error paths: concise toast + retry in HITL form. + +--- + +## 6) Milestones +- M1 (Done): Page + readables + predictive edits + suggestions (start/edit) + health/tools probe. +- M2 (Done): HITL for post.generate; integrate API; hashtags action; editor sync. +- M3 (Updated): Ad copy (done), Variations UI (done), Story (done), context-aware suggestions (ongoing), tests (pending). +- M4 (Planned): Reel/Carousel; variants pipeline; scheduling hooks; session persistence (optional). + +### 6.1 Next-phase Tasks (Detailed) +- Ad Copy (M3) + - Suggestion chips: “Create ad copy”, “Short ad variant (primary text)”, “Insert headline X”. + - A/B insert UX: quick insert/replace buttons already present; add multi-insert queue. +- Story (M3) + - HITL toggle for image generation on/off; regenerate button; image count (1–3) cap. + - Gallery UX: copy/download, insert image markdown into draft, or upload to asset store. + - Improve visual prompt composition from form fields (brand + tone + CTA region). +- Context-aware Suggestions (M3) + - Derive stage features: draft length buckets, tone inferred from text, presence of CTA/hashtags. + - Swap suggestion sets accordingly; include “Summarize intro” for long drafts. +- Confirm/Reject for Predictive Edits (M3) + - Option: preference to auto-confirm future edits. +- Tests (M3) + - Unit test action handlers (param mapping, event dispatch), reducer-like state transitions. + - Snapshot test suggestion sets for start/edit/long-draft. + - API client smoke tests for post/hashtags/ad-copy/story. +- Observability (M3) + - Verify `/api/facebook-writer/*` counters in header; add tags for route family. + - Log action success/error counts. +- Session Persistence (M4, optional) + - Backend `copilot_sessions` + `messages` tables; persist assistant/user messages. + - Provide `sessionId` per user/page; prehydrate sidebar from server. +- Next endpoints (M4) + - Implement reel/carousel/event/group/page-about endpoints with parity HITL forms. + +### 6.2 Known limitations / Non-goals (for now) +- Image generation: Gemini outputs include SynthID watermark; outputs not guaranteed each call; currently generates 1 image for story. +- Cost/quotas: No server-side budgeting/limits yet for image gen; add per-user caps and caching. +- Asset pipeline: No upload/CDN integration yet; images are rendered inline as base64. + +--- + +## 7) Risks & Mitigations +- Enum mismatches → Use exact server enum strings; surface helpful errors. +- Long outputs → Clamp `max_tokens` server-side; provide “shorten” action client-side. +- Rate limiting → Respect retry/backoff; keep client timeouts reasonable. + +Reference (Gemini image generation best practices): https://ai.google.dev/gemini-api/docs/image-generation + +--- + +## 8) Success Criteria +- End-to-end draft creation via Copilot with a single click (HITL). +- Predictive state edits observable in real-time. +- Monitoring reflects API usage in the header control. +- Clean, reproducible flows for post + hashtags; extendable to ads and other tools. diff --git a/frontend/src/components/FacebookWriter/FacebookWriter.tsx b/frontend/src/components/FacebookWriter/FacebookWriter.tsx index 13e26103..7ea700e1 100644 --- a/frontend/src/components/FacebookWriter/FacebookWriter.tsx +++ b/frontend/src/components/FacebookWriter/FacebookWriter.tsx @@ -1,14 +1,209 @@ import React from 'react'; -import { Box, Container, Typography, TextField, Paper } from '@mui/material'; +import { Box, Container, Typography, TextField, Paper, Button } from '@mui/material'; import { CopilotSidebar } from '@copilotkit/react-ui'; import { useCopilotReadable, useCopilotAction } from '@copilotkit/react-core'; import '@copilotkit/react-ui/styles.css'; +import RegisterFacebookActions from './RegisterFacebookActions'; +import RegisterFacebookEditActions from './RegisterFacebookEditActions'; const useCopilotActionTyped = useCopilotAction as any; +// --- Simple localStorage-backed chat memory --- +const HISTORY_KEY = 'fbwriter:chatHistory'; +const PREFS_KEY = 'fbwriter:preferences'; + +type ChatMsg = { role: 'user' | 'assistant'; content: string; ts: number }; + +function loadHistory(): ChatMsg[] { + try { + const raw = localStorage.getItem(HISTORY_KEY); + if (!raw) return []; + const arr = JSON.parse(raw); + if (!Array.isArray(arr)) return []; + return arr.filter((m: any) => m && typeof m.content === 'string' && (m.role === 'user' || m.role === 'assistant')); + } catch { return []; } +} + +function saveHistory(msgs: ChatMsg[]) { + try { localStorage.setItem(HISTORY_KEY, JSON.stringify(msgs.slice(-50))); } catch {} +} + +function pushHistory(role: 'user' | 'assistant', content: string) { + const msgs = loadHistory(); + msgs.push({ role, content: String(content || '').slice(0, 4000), ts: Date.now() }); + saveHistory(msgs); +} + +function clearHistory() { + try { localStorage.removeItem(HISTORY_KEY); } catch {} +} + +function getPreferences(): Record { + 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 += `${escapeHtml(a[i])}`; + i++; + } else { + out += `${escapeHtml(b[j])}`; + j++; + } + } + while (i < n) { out += `${escapeHtml(a[i++])}`; } + while (j < m) { out += `${escapeHtml(b[j++])}`; } + if (oldText.length > MAX || newText.length > MAX) out += ''; + return out; +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +function simpleMarkdownToHtml(markdown: string): string { + // Very small, safe-ish markdown renderer for bold, italics, lists, headings, paragraphs + // 1) Escape HTML first + let html = escapeHtml(markdown || ''); + // 2) Headings (##, # at line start) + html = html.replace(/^###\s+(.*)$/gm, '

$1

'); + html = html.replace(/^##\s+(.*)$/gm, '

$1

'); + html = html.replace(/^#\s+(.*)$/gm, '

$1

'); + // 3) Bold and italics + html = html.replace(/\*\*(.*?)\*\*/g, '$1'); + html = html.replace(/\*(.*?)\*/g, '$1'); + // 4) Lists: lines starting with * or - + html = html.replace(/^(?:\*|-)\s+(.+)$/gm, '
  • $1
  • '); + // Wrap consecutive
  • into
      + html = html.replace(/(
    • .*<\/li>)(\s*(
    • .*<\/li>))+/gms, (m) => `
        ${m}
      `); + // 5) Line breaks → paragraphs + html = html.replace(/^(?!|
        |
      • )(.+)$/gm, '

        $1

        '); + // Remove paragraphs around list items + html = html.replace(/

        (

      • .*?<\/li>)<\/p>/gms, '$1'); + html = html.replace(/

        (

          .*?<\/ul>)<\/p>/gms, '$1'); + return html; +} + const FacebookWriter: React.FC = () => { const [postDraft, setPostDraft] = React.useState(''); const [notes, setNotes] = React.useState(''); + const [stage, setStage] = React.useState<'start' | 'edit'>('start'); + const [livePreviewHtml, setLivePreviewHtml] = React.useState(''); + const [isPreviewing, setIsPreviewing] = React.useState(false); + const [pendingEdit, setPendingEdit] = React.useState<{ src: string; target: string } | null>(null); + const [historyVersion, setHistoryVersion] = React.useState(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(null); + const renderRef = React.useRef(null); + const [selectionMenu, setSelectionMenu] = React.useState<{ x: number; y: number; text: string } | null>(null); + + React.useEffect(() => { + const onUpdate = (e: any) => { + setPostDraft(String(e.detail || '')); + setStage('edit'); + }; + const onAppend = (e: any) => { + setPostDraft(prev => (prev || '') + String(e.detail || '')); + setStage('edit'); + }; + const onAssistantMessage = (e: any) => { + const content = e?.detail?.content ?? e?.detail ?? ''; + if (content) { + pushHistory('assistant', String(content)); + setHistoryVersion(v => v + 1); + } + }; + const onApplyEdit = (e: any) => { + const op = (e?.detail?.operation || '').toLowerCase(); + const src = postDraft || ''; + const target = computeEditedText(op, src); + setIsPreviewing(true); + setStage('edit'); + setPendingEdit({ src, target }); + let idx = 0; + const total = target.length; + const intervalMs = 20; + const step = Math.max(1, Math.floor(total / 120)); // ~2 seconds + const interval = setInterval(() => { + idx += step; + if (idx >= total) idx = total; + const partial = target.slice(0, idx); + setLivePreviewHtml(diffMarkup(src, partial)); + if (idx === total) { + clearInterval(interval); + // Keep preview open and wait for user to confirm or discard. + } + }, intervalMs); + }; + window.addEventListener('fbwriter:updateDraft', onUpdate as any); + window.addEventListener('fbwriter:appendDraft', onAppend as any); + window.addEventListener('fbwriter:assistantMessage', onAssistantMessage as any); + const onAdVariations = (e: any) => { + const v = e?.detail; + if (v) setAdVariations(v); + }; + const onStoryImages = (e: any) => { + const imgs = e?.detail; + if (Array.isArray(imgs) && imgs.length) setStoryImages(imgs); + }; + window.addEventListener('fbwriter:applyEdit', onApplyEdit as any); + window.addEventListener('fbwriter:adVariations', onAdVariations as any); + window.addEventListener('fbwriter:storyImages', onStoryImages as any); + return () => { + window.removeEventListener('fbwriter:updateDraft', onUpdate as any); + window.removeEventListener('fbwriter:appendDraft', onAppend as any); + window.removeEventListener('fbwriter:assistantMessage', onAssistantMessage as any); + window.removeEventListener('fbwriter:applyEdit', onApplyEdit as any); + window.removeEventListener('fbwriter:adVariations', onAdVariations as any); + window.removeEventListener('fbwriter:storyImages', onStoryImages as any); + }; + }, [postDraft]); // Share current draft and notes with Copilot useCopilotReadable({ @@ -31,6 +226,7 @@ const FacebookWriter: React.FC = () => { ], handler: async ({ content }: { content: string }) => { setPostDraft(content); + setStage('edit'); return { success: true, message: 'Draft updated' }; } }); @@ -44,36 +240,115 @@ const FacebookWriter: React.FC = () => { ], handler: async ({ content }: { content: string }) => { setPostDraft(prev => (prev ? `${prev}\n\n${content}` : content)); + setStage('edit'); return { success: true, message: 'Text appended' }; } }); - const suggestions = [ - { title: '🎉 Launch teaser', message: 'Write a short Facebook post announcing our new feature launch' }, - { title: '💡 Benefit-first', message: 'Draft a Facebook post highlighting a key user benefit with a CTA' }, - { title: '🔁 Variations', message: 'Generate 3 alternative versions of this post to A/B test' }, - { title: '🏷️ Hashtags', message: 'Suggest 5 relevant hashtags for this post' } + const startSuggestions = [ + { title: '🎉 Launch teaser', message: 'Use tool generateFacebookPost to write a short Facebook post announcing our new feature launch.' }, + { title: '💡 Benefit-first', message: 'Use tool generateFacebookPost to draft a benefit-first Facebook post with a strong CTA.' }, + { title: '🏷️ Hashtags', message: 'Use tool generateFacebookHashtags to suggest 5 relevant hashtags for this post.' }, + { title: '📢 Ad copy (primary text)', message: 'Use tool generateFacebookAdCopy to create ad copy tailored for conversions.' }, + { title: '📚 Story', message: 'Use tool generateFacebookStory to create a Facebook Story script with tone and visuals.' }, + { title: '🎬 Reel script', message: 'Use tool generateFacebookReel to draft a 30-60 seconds fast-paced product demo reel with hook, scenes, and CTA.' }, + { title: '🖼️ Carousel', message: 'Use tool generateFacebookCarousel to create a 5-slide Product showcase carousel with a main caption and CTA.' }, + { title: '📅 Event', message: 'Use tool generateFacebookEvent to create a Virtual Webinar event description with title, highlights, and CTA.' } ]; + const editSuggestions = [ + { title: '🙂 Make it casual', message: 'Use tool editFacebookDraft with operation Casual' }, + { title: '💼 Make it professional', message: 'Use tool editFacebookDraft with operation Professional' }, + { title: '✨ Tighten hook', message: 'Use tool editFacebookDraft with operation TightenHook' }, + { title: '📣 Add a CTA', message: 'Use tool editFacebookDraft with operation AddCTA' }, + { title: '✂️ Shorten', message: 'Use tool editFacebookDraft with operation Shorten' }, + { title: '➕ Lengthen', message: 'Use tool editFacebookDraft with operation Lengthen' } + ]; + + // Stage-aware suggestion refinement + const hasCTA = /\b(call now|sign up|join|try|learn more|cta|comment|share|buy|shop)\b/i.test(postDraft); + const hasHashtags = /#[A-Za-z0-9_]+/.test(postDraft); + const isLong = (postDraft || '').length > 500; + const refinedEdit = [ + ...editSuggestions, + ...(isLong ? [{ title: '📝 Summarize intro', message: 'Use tool editFacebookDraft with operation Shorten' }] : []), + ...(!hasCTA ? [{ title: '📣 Add a CTA', message: 'Use tool editFacebookDraft with operation AddCTA' }] : []), + ...(!hasHashtags ? [{ title: '🏷️ Add hashtags', message: 'Use tool generateFacebookHashtags' }] : []) + ]; + const suggestions = stage === 'start' ? startSuggestions : refinedEdit; return ( { + 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 }) + }} > - - - - Facebook Writer (Preview) - + + + + + + + Facebook Writer (Preview) + + + + + + - 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.'} - + Context/Notes (optional) @@ -87,32 +362,198 @@ const FacebookWriter: React.FC = () => { sx={{ mb: 2, '& .MuiInputBase-root': { color: 'white' }, - '& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.3)' } - }} - /> - - - Post Draft - - setPostDraft(e.target.value)} - placeholder="Your Facebook post will appear here. Ask the Copilot to draft or update it." - sx={{ - '& .MuiInputBase-root': { color: 'white' }, - '& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.3)' } + '& .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.35)' }, + '& .MuiInputBase-input::placeholder': { color: 'rgba(255,255,255,0.7)' } }} /> + + + + + Post Draft (rendered) + + + + {isPreviewing && ( + + + Live changes preview + +
          + + {pendingEdit && ( + + + + + )} + + )} + + { + 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' } + }}> +
          + {selectionMenu && ( + + + + + + + )} + + + + {Array.isArray(storyImages) && storyImages.length > 0 && ( + + + Story Images + + + {storyImages.map((b64, idx) => ( + {`story-${idx}`} + ))} + + + )} + + {adVariations && ( + + + Ad Variations + + + setPostDraft(prev => prev ? `${t}\n\n${prev}` : t)} onReplace={(t)=>setPostDraft(t)} /> + setPostDraft(prev => prev ? `${prev}\n\n${t}` : t)} onReplace={(t)=>setPostDraft(t)} /> + setPostDraft(prev => prev ? `${prev}\n\n${t}` : t)} onReplace={(t)=>setPostDraft(t)} /> + setPostDraft(prev => prev ? `${prev}\n\n${t}` : t)} onReplace={(t)=>setPostDraft(t)} /> + + + )} ); }; +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 ( + + {title} + + {items.slice(0, 5).map((t, idx) => ( + + {t} + + + + + + ))} + + + ); +}; + export default FacebookWriter; diff --git a/frontend/src/components/FacebookWriter/RegisterFacebookActions.tsx b/frontend/src/components/FacebookWriter/RegisterFacebookActions.tsx new file mode 100644 index 00000000..b1736156 --- /dev/null +++ b/frontend/src/components/FacebookWriter/RegisterFacebookActions.tsx @@ -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 { try { return JSON.parse(localStorage.getItem(PREFS_KEY) || '{}') || {}; } catch { return {}; } } +function writePrefs(p: Record) { 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({ + 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(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 ( +
          +
          Generate Facebook Post
          +
          + set('business_type', e.target.value)} /> + set('target_audience', e.target.value)} /> + set('post_goal', e.target.value)} /> + set('post_tone', e.target.value)} /> + set('include', e.target.value)} /> + set('avoid', e.target.value)} /> +
          + + {error &&
          {error}
          } +
          + ); +}; + +const HashtagsHITL: React.FC<{ args: any; respond: (data: any) => void }> = ({ args, respond }) => { + const [topic, setTopic] = React.useState(args?.content_topic || 'product launch'); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(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 ( +
          +
          Generate Hashtags
          + setTopic(e.target.value)} /> + + {error &&
          {error}
          } +
          + ); +}; + +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(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 ( +
          +
          Generate Event
          +
          + set('event_name', e.target.value)} /> + set('event_type', e.target.value)} /> + set('event_format', e.target.value)} /> + set('business_type', e.target.value)} /> + set('target_audience', e.target.value)} /> + set('event_date', e.target.value)} /> + set('event_time', e.target.value)} /> + set('location', e.target.value)} /> + set('duration', e.target.value)} /> + set('key_benefits', e.target.value)} /> + set('speakers', e.target.value)} /> + set('agenda', e.target.value)} /> + set('ticket_info', e.target.value)} /> + set('special_offers', e.target.value)} /> + set('include', e.target.value)} /> + set('avoid', e.target.value)} /> +
          + + {error &&
          {error}
          } +
          + ); + }; + return ; + }, + 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(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 ( +
          +
          Generate Carousel
          +
          + set('business_type', e.target.value)} /> + set('target_audience', e.target.value)} /> + set('carousel_type', e.target.value)} /> + set('topic', e.target.value)} /> + set('num_slides', Number(e.target.value) || 5)} /> + + set('cta_text', e.target.value)} /> + set('brand_colors', e.target.value)} /> + set('include', e.target.value)} /> + set('avoid', e.target.value)} /> +
          + + {error &&
          {error}
          } +
          + ); + }; + return ; + }, + 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(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 ( +
          +
          Generate Reel
          +
          + set('business_type', e.target.value)} /> + set('target_audience', e.target.value)} /> + set('reel_type', e.target.value)} /> + set('reel_length', e.target.value)} /> + set('reel_style', e.target.value)} /> + set('topic', e.target.value)} /> + set('include', e.target.value)} /> + set('avoid', e.target.value)} /> + set('music_preference', e.target.value)} /> +
          + + {error &&
          {error}
          } +
          + ); + }; + return ; + }, + 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) => , + 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) => , + 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(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 ( +
          +
          Generate Ad Copy
          +
          + set('business_type', e.target.value)} /> + set('product_service', e.target.value)} /> + set('ad_objective', e.target.value)} /> + set('ad_format', e.target.value)} /> + set('target_audience', e.target.value)} /> +
          +
          Targeting
          + setNested('age_group', e.target.value)} /> + setNested('gender', e.target.value)} /> + setNested('location', e.target.value)} /> + setNested('interests', e.target.value)} /> +
          + set('unique_selling_proposition', e.target.value)} /> + set('offer_details', e.target.value)} /> + set('budget_range', e.target.value)} /> + set('campaign_duration', e.target.value)} /> + set('brand_voice', e.target.value)} /> +
          + + {error &&
          {error}
          } +
          + ); + }; + + 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) => , + 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(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 ( +
          +
          Generate Story
          +
          + set('business_type', e.target.value)} /> + set('target_audience', e.target.value)} /> + set('story_type', e.target.value)} /> + set('story_tone', e.target.value)} /> + set('include', e.target.value)} /> + set('avoid', e.target.value)} /> +
          Advanced options
          + + + + + + + +
          Visual options
          + setVisual('background_type', e.target.value)} /> + setVisual('background_image_prompt', e.target.value)} /> + setVisual('gradient_style', e.target.value)} /> + + setVisual('text_style', e.target.value)} /> + setVisual('text_color', e.target.value)} /> + setVisual('text_position', e.target.value)} /> + + + setVisual('interactive_types', parseInteractive(e.target.value))} /> + setVisual('call_to_action', e.target.value)} /> +
          + + {error &&
          {error}
          } +
          + ); + }; + + 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) => , + 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; + + diff --git a/frontend/src/components/FacebookWriter/RegisterFacebookEditActions.tsx b/frontend/src/components/FacebookWriter/RegisterFacebookEditActions.tsx new file mode 100644 index 00000000..88c0b1ff --- /dev/null +++ b/frontend/src/components/FacebookWriter/RegisterFacebookEditActions.tsx @@ -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(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 ( +
          +
          Edit Draft
          +
          + {ops.map(o => ( + + ))} +
          + +
          + ); +}; + +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) => , + 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; + + diff --git a/frontend/src/components/SEODashboard/actions/RegisterTechnical.tsx b/frontend/src/components/SEODashboard/actions/RegisterTechnical.tsx index d5906730..a89edcdf 100644 --- a/frontend/src/components/SEODashboard/actions/RegisterTechnical.tsx +++ b/frontend/src/components/SEODashboard/actions/RegisterTechnical.tsx @@ -40,7 +40,7 @@ const TechnicalUI: React.FC<{ args: any; respond: (data: any) => void }> = ({ ar {['full', 'core_web_vitals', 'mobile_friendliness', 'security'].map((s) => ( ))}
          diff --git a/frontend/src/services/facebookWriterApi.ts b/frontend/src/services/facebookWriterApi.ts new file mode 100644 index 00000000..38eeb168 --- /dev/null +++ b/frontend/src/services/facebookWriterApi.ts @@ -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 { + const { data } = await apiClient.get('/api/facebook-writer/health'); + return data; + }, + async tools(): Promise { + const { data } = await apiClient.get('/api/facebook-writer/tools'); + return data; + }, + async postGenerate(payload: PostGenerateRequest): Promise { + const { data } = await apiClient.post('/api/facebook-writer/post/generate', payload); + return data; + }, + async hashtagsGenerate(payload: { content_topic?: string; draft?: string }): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const { data } = await apiClient.post('/api/facebook-writer/event/generate', payload); + return data; + } +}; + +