From 58700fcec77c614f6193b73c1613fcace235faf2 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 01:15:22 +0530 Subject: [PATCH 01/20] feat:image support for captions --- app/src/lib/tools/caption-generator.ts | 80 +++++++++----------------- 1 file changed, 27 insertions(+), 53 deletions(-) diff --git a/app/src/lib/tools/caption-generator.ts b/app/src/lib/tools/caption-generator.ts index a419e17..0350cbf 100644 --- a/app/src/lib/tools/caption-generator.ts +++ b/app/src/lib/tools/caption-generator.ts @@ -8,58 +8,32 @@ export const captionGenerator: ToolDefinition = { category: "content", icon: "PenTool", status: "active", - - requiredFields: ["contentDescription"], - defaultModel: "llama-3.3-70b", - - buildSystemPrompt: ({ platform }) => - `You are a social media content strategist. Generate engaging, platform-optimized captions. Rules: - -1. **Match the platform tone** - ${platform || "All platforms"} style and conventions -2. **Hook first** - Start with an attention-grabbing line -3. **Include CTAs** - ask questions, invite engagement -4. **Hashtags** - 5-10 relevant hashtags (platform-appropriate) -5. **Emojis** - Use strategically, not excessively -6. **Character limits** - Respect platform limits (Twitter: 280, Instagram caption: 2200) - -Generate 3 caption variations: Professional, Casual, and Bold/Edgy.`, - - buildUserPrompt: ({ contentDescription, platform, tone, cta }) => - `**CONTENT:** ${contentDescription}\n\n**PLATFORM:** ${platform || "All platforms"}\n\n${tone ? `**TONE:** ${tone}\n` : ""}${cta ? `**CALL TO ACTION:** ${cta}\n` : ""}\n\nGenerate 3 caption variations.`, + tier: "tier2", + requiredFields: ["prompt", "platform", "style"], + defaultModel: "kimi-k2.5", + buildSystemPrompt: () => "", + buildUserPrompt: () => "", inputs: [ - { - key: "contentDescription", - label: "Content Description", - type: "textarea", - placeholder: - "E.g. 'We just launched our AI-powered developer tools platform. It has 22+ free tools for debugging, testing, and code generation.'", - rows: 4, - }, - { - key: "platform", - label: "Platform", - type: "select", - options: [ - { value: "All platforms", label: "All Platforms" }, - { value: "Instagram", label: "Instagram" }, - { value: "Twitter/X", label: "Twitter / X" }, - { value: "LinkedIn", label: "LinkedIn" }, - { value: "TikTok", label: "TikTok" }, - { value: "YouTube", label: "YouTube (description)" }, - ], - }, - { - key: "tone", - label: "Tone (optional)", - type: "text", - placeholder: "E.g. 'Professional but approachable'", - }, - { - key: "cta", - label: "Call to Action (optional)", - type: "text", - placeholder: "E.g. 'Sign up for the beta'", - }, - ], -}; + { key: "prompt", label: "Prompt", type: "textarea", rows: 4, placeholder: "E.g. 'We just launched our AI-powered developer tools platform. It has 22+ free tools for debugging, testing, and code generation.'"}, + { key: "platform", label: "Platform", type: "select", + options: [ + { value: "All platform", label: "All Platforms" }, + { value: "Instagram", label: "Instagram" }, + { value: "X(Twitter)", label: "X (Twitter)" }, + { value: "Linkedin", label: "LinkedIn" }, + { value: "Youtube", label: "YouTube" }, + { value: "Tiktok", label: "TikTok" }, + ] }, + + // { key: "style", label: "Style", type: "select", + // options: [ + // { value: "professional", label: "Professional" }, + // { value: "casual", label: "Casual" }, + // { value: "bold", label: "Bold" }, + // ], + // defaultValue: "professional" }, + + { key: "image", label: "Image (optional)", type: "image", } + ], +}; \ No newline at end of file From 044c8fd681b63633fdaa15d23807fa2c7660d42c Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 01:21:09 +0530 Subject: [PATCH 02/20] feat: caption-generator tool main --- .../tools/caption-generator/requirement.txt | 1 + services/python-tools/tools/caption-generator/tool.py | 10 ++++++++++ 2 files changed, 11 insertions(+) create mode 100644 services/python-tools/tools/caption-generator/requirement.txt create mode 100644 services/python-tools/tools/caption-generator/tool.py diff --git a/services/python-tools/tools/caption-generator/requirement.txt b/services/python-tools/tools/caption-generator/requirement.txt new file mode 100644 index 0000000..3ceaffc --- /dev/null +++ b/services/python-tools/tools/caption-generator/requirement.txt @@ -0,0 +1 @@ +openai>=1.0.0 \ No newline at end of file diff --git a/services/python-tools/tools/caption-generator/tool.py b/services/python-tools/tools/caption-generator/tool.py new file mode 100644 index 0000000..8fe4acb --- /dev/null +++ b/services/python-tools/tools/caption-generator/tool.py @@ -0,0 +1,10 @@ +MANIFEST = { + "id": "caption-generator", + "name": "Social Media Captions", + "description": "Describe your content and get platform-optimized captions with hashtags for Instagram, Twitter/X, LinkedIn, and more.", + "author": "Oxlo Team", + "version": "2.0.0", +} + +async def run(data:dict)->dict: + return {"result": "", "metadata": {}} \ No newline at end of file From 82af6dc1cd5304acfe142cf095ae8e4c19e0a526 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 01:25:48 +0530 Subject: [PATCH 03/20] feat: rules for diff social media platform --- .../tools/caption-generator/rules.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 services/python-tools/tools/caption-generator/rules.py diff --git a/services/python-tools/tools/caption-generator/rules.py b/services/python-tools/tools/caption-generator/rules.py new file mode 100644 index 0000000..2b19319 --- /dev/null +++ b/services/python-tools/tools/caption-generator/rules.py @@ -0,0 +1,61 @@ +# platform rules and variation for posts + + +PLATFORM_RULES = { + "X(Twitter)": { + "name": "X (Twitter)", + "max_length": 280, + "hashtag_count": (2, 3), + "cta_patterns": ["Link in bio", "Click the link", "Learn more", "Read more"], + "emoji_limit": (1, 3) + }, + "Instagram": { + "name": "Instagram", + "max_length": 2200, + "hashtag_count": (5, 8), + "cta_patterns": ["Double tap if you agree", "Tag someone", "Share with a friend", "Link in bio"], + "emoji_limit": (3, 6) + }, + "Linkedin": { + "name": "LinkedIn", + "max_length": 3000, + "hashtag_count": (3, 5), + "cta_patterns": ["What are your thoughts?", "Share your experience", "Let's connect", "Comments welcome"], + "emoji_limit": (2, 5) + }, + "Youtube": { + "name": "YouTube", + "max_length": 5000, + "hashtag_count": (3, 5), + "cta_patterns": ["Like and subscribe", "Let me know in comments", "Share your thoughts", "Don't forget to subscribe"], + "emoji_limit": (1, 3) + }, + "Tiktok": { + "name": "TikTok", + "max_length": 2200, + "hashtag_count": (3, 5), + "cta_patterns": ["Follow for more", "Like and follow", "Duet this", "Share with friends"], + "emoji_limit": (2, 5) + }, +} + +VARIATION_STYLES = { + "professional": { + "name": "Professional", + "description": "Formal, polished, business-appropriate tone", + "characteristics": ["Clear and concise", "Professional vocabulary", "Minimal emoji", "Structured"] + }, + "casual": { + "name": "Casual", + "description": "Friendly, conversational, approachable tone", + "characteristics": ["Warm language", "Light emoji", "Conversational", "Engaging"] + }, + "bold": { + "name": "Bold", + "description": "Confident, attention-grabbing, high-impact tone", + "characteristics": ["Power words", "Strong statements", "More emoji", "Call to action"] + }, +} + +MAX_RETRIES = 2 +SIMILARITY_THRESHOLD = 0.70 \ No newline at end of file From 10510432c9745f20f198fe80b85cc3568cbf851d Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 01:29:39 +0530 Subject: [PATCH 04/20] feat: add similarity checking --- .../tools/caption-generator/helper.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 services/python-tools/tools/caption-generator/helper.py diff --git a/services/python-tools/tools/caption-generator/helper.py b/services/python-tools/tools/caption-generator/helper.py new file mode 100644 index 0000000..ebe9a98 --- /dev/null +++ b/services/python-tools/tools/caption-generator/helper.py @@ -0,0 +1,25 @@ +from typing import List, Tuple + +def jaccard_similarity(text1: str, text2: str) -> float: + words1 = set(text1.lower().split()) + words2 = set(text2.lower().split()) + + if not words1 or not words2: + return 0.0 + + intersection = words1.intersection(words2) + union = words1.union(words2) + + return len(intersection) / len(union) if union else 0.0 + + +def check_variations(captions: List[str]) -> List[Tuple[int, int, float]]: + similarities = [] + n = len(captions) + + for i in range(n): + for j in range(i + 1, n): + sim = jaccard_similarity(captions[i], captions[j]) + similarities.append((i, j, sim)) + + return similarities \ No newline at end of file From 3446463ea62b7e345fc8edf9c302342b8ee7854d Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 01:53:21 +0530 Subject: [PATCH 05/20] feat: add caption gen for posts --- .../tools/caption-generator/generator.py | 45 +++++++++++++++++++ .../tools/caption-generator/gnerator.py | 45 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 services/python-tools/tools/caption-generator/generator.py create mode 100644 services/python-tools/tools/caption-generator/gnerator.py diff --git a/services/python-tools/tools/caption-generator/generator.py b/services/python-tools/tools/caption-generator/generator.py new file mode 100644 index 0000000..3d56ee4 --- /dev/null +++ b/services/python-tools/tools/caption-generator/generator.py @@ -0,0 +1,45 @@ +import asyncio +from openai import AsyncOpenAI +from rules import PLATFORM_RULES, VARIATION_STYLES + +async def extra_text(image_data:str, api_key: str) ->str: + client = AsyncOpenAI( + base_url="https://api.oxlo.ai/v1", + api_key=api_key + ) + +async def generate_caption( + client: AsyncOpenAI, + prompt: str, + platform: str, + style: str, + context_from_image: str = "" +) -> str: + + platform_info = PLATFORM_RULES.get(platform, PLATFORM_RULES["linkedin"]) + style_info = VARIATION_STYLES.get(style, VARIATION_STYLES["professional"]) + + enhanced_prompt = f"""Generate a {style} social media caption for {platform_info['name']}. + +Requirements: +- Maximum {platform_info['max_length']} characters +- Use {platform_info['hashtag_count'][0]}-{platform_info['hashtag_count'][1]} relevant hashtags +- Include 1-2 emojis appropriate for the platform +- Add a clear call-to-action from these options: {', '.join(platform_info['cta_patterns'])} +- Style: {style_info['description']} +- Characteristics: {', '.join(style_info['characteristics'])} + +User's original prompt: {prompt}""" + + if context_from_image: + enhanced_prompt += f"\n\nAdditional context from image: {context_from_image}" + + response = await client.chat.completions.create( + model="kimi-k2.5", + messages=[ + {"role": "user", "content": enhanced_prompt} + ], + max_tokens=500, + ) + + return response.choices[0].message.content \ No newline at end of file diff --git a/services/python-tools/tools/caption-generator/gnerator.py b/services/python-tools/tools/caption-generator/gnerator.py new file mode 100644 index 0000000..3d56ee4 --- /dev/null +++ b/services/python-tools/tools/caption-generator/gnerator.py @@ -0,0 +1,45 @@ +import asyncio +from openai import AsyncOpenAI +from rules import PLATFORM_RULES, VARIATION_STYLES + +async def extra_text(image_data:str, api_key: str) ->str: + client = AsyncOpenAI( + base_url="https://api.oxlo.ai/v1", + api_key=api_key + ) + +async def generate_caption( + client: AsyncOpenAI, + prompt: str, + platform: str, + style: str, + context_from_image: str = "" +) -> str: + + platform_info = PLATFORM_RULES.get(platform, PLATFORM_RULES["linkedin"]) + style_info = VARIATION_STYLES.get(style, VARIATION_STYLES["professional"]) + + enhanced_prompt = f"""Generate a {style} social media caption for {platform_info['name']}. + +Requirements: +- Maximum {platform_info['max_length']} characters +- Use {platform_info['hashtag_count'][0]}-{platform_info['hashtag_count'][1]} relevant hashtags +- Include 1-2 emojis appropriate for the platform +- Add a clear call-to-action from these options: {', '.join(platform_info['cta_patterns'])} +- Style: {style_info['description']} +- Characteristics: {', '.join(style_info['characteristics'])} + +User's original prompt: {prompt}""" + + if context_from_image: + enhanced_prompt += f"\n\nAdditional context from image: {context_from_image}" + + response = await client.chat.completions.create( + model="kimi-k2.5", + messages=[ + {"role": "user", "content": enhanced_prompt} + ], + max_tokens=500, + ) + + return response.choices[0].message.content \ No newline at end of file From 6bc36f2da9ec53b4d6081a10ab002d244f7bc1b3 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 03:00:19 +0530 Subject: [PATCH 06/20] feat: create caption generator tool --- .../tools/caption-generator/tool.py | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/services/python-tools/tools/caption-generator/tool.py b/services/python-tools/tools/caption-generator/tool.py index 8fe4acb..4458c86 100644 --- a/services/python-tools/tools/caption-generator/tool.py +++ b/services/python-tools/tools/caption-generator/tool.py @@ -1,3 +1,10 @@ +import os +from openai import AsyncOpenAI +import asyncio +from generator import extra_text, generate_caption +from rules import MAX_RETRIES, SIMILARITY_THRESHOLD +from helper import check_variations + MANIFEST = { "id": "caption-generator", "name": "Social Media Captions", @@ -7,4 +14,68 @@ } async def run(data:dict)->dict: - return {"result": "", "metadata": {}} \ No newline at end of file + prompt = data.get("prompt", "") + image_data = data.get("image", "") + platform = data.get("platform", "linkedin").lower() + api_key = os.getenv("OXLO_API_KEY") + + if not api_key: + return { + "error": "Please enter your Oxlo API key.", + "result": None + } + + if not prompt: + return { + "error": "Please enter prompt for captions.", + "result": None + } + + client = AsyncOpenAI( + base_url="https://api.oxlo.ai/v1", + api_key=api_key, + ) + + #text from image + context_from_image = "" + if image_data: + context_from_image = await extra_text(image_data, api_key) + + platforms = [platform] if platform != "all" else ["twitter", "linkedin", "instagram", "youtube", "tiktok"] + + all_results = {} + for p in platforms: + captions = [] + retry_count = 0 + while len(captions) < 3 and retry_count <= MAX_RETRIES: + # Generate captions + new_captions = await asyncio.gather( + generate_caption(client, prompt, p, "professional", context_from_image), + generate_caption(client, prompt, p, "casual", context_from_image), + generate_caption(client, prompt, p, "bold", context_from_image), + ) + + similarities = check_variations(new_captions) + + high_similarity_pairs = [(i, j, s) for i, j, s in similarities if s > SIMILARITY_THRESHOLD] + + if high_similarity_pairs and retry_count < MAX_RETRIES: + regenerate_indices = set() + for i, j, s in high_similarity_pairs: + regenerate_indices.add(i) + regenerate_indices.add(j) + + #have non-similar captions, regenerate others + captions = [c for idx, c in enumerate(new_captions) if idx not in regenerate_indices] + + for idx in regenerate_indices: + style_map = {0: "professional", 1: "casual", 2: "bold"} + new_cap = await generate_caption(client, prompt, p, style_map[idx], context_from_image) + captions.append(new_cap) + + retry_count += 1 + else: + captions = new_captions + break + + return {"result": "", "metadata": {}} From 1fa0faa03f3c41d2d7ca26f6ed70b2ea46387182 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 04:02:45 +0530 Subject: [PATCH 07/20] feat: caption generator tool --- app/src/lib/tools/caption-generator.ts | 4 +- .../tools/caption-generator/tool.py | 57 ++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/app/src/lib/tools/caption-generator.ts b/app/src/lib/tools/caption-generator.ts index 0350cbf..e0663ca 100644 --- a/app/src/lib/tools/caption-generator.ts +++ b/app/src/lib/tools/caption-generator.ts @@ -9,7 +9,7 @@ export const captionGenerator: ToolDefinition = { icon: "PenTool", status: "active", tier: "tier2", - requiredFields: ["prompt", "platform", "style"], + requiredFields: ["prompt", "platform"], defaultModel: "kimi-k2.5", buildSystemPrompt: () => "", buildUserPrompt: () => "", @@ -35,5 +35,5 @@ export const captionGenerator: ToolDefinition = { // defaultValue: "professional" }, { key: "image", label: "Image (optional)", type: "image", } - ], + ] }; \ No newline at end of file diff --git a/services/python-tools/tools/caption-generator/tool.py b/services/python-tools/tools/caption-generator/tool.py index 4458c86..29b4bd0 100644 --- a/services/python-tools/tools/caption-generator/tool.py +++ b/services/python-tools/tools/caption-generator/tool.py @@ -2,7 +2,7 @@ from openai import AsyncOpenAI import asyncio from generator import extra_text, generate_caption -from rules import MAX_RETRIES, SIMILARITY_THRESHOLD +from rules import MAX_RETRIES, SIMILARITY_THRESHOLD, PLATFORM_RULES from helper import check_variations MANIFEST = { @@ -16,7 +16,12 @@ async def run(data:dict)->dict: prompt = data.get("prompt", "") image_data = data.get("image", "") - platform = data.get("platform", "linkedin").lower() + platform = data.get("platform", "linkedin") + platform_lower = platform.lower() + if platform_lower == "all platform": + platform = "all" + else: + platform = platform_lower api_key = os.getenv("OXLO_API_KEY") if not api_key: @@ -41,14 +46,21 @@ async def run(data:dict)->dict: if image_data: context_from_image = await extra_text(image_data, api_key) - platforms = [platform] if platform != "all" else ["twitter", "linkedin", "instagram", "youtube", "tiktok"] + platform_map = { + "twitter": "X(Twitter)", + "instagram": "Instagram", + "linkedin": "Linkedin", + "youtube": "Youtube", + "tiktok": "Tiktok" + } + platforms = [platform] if platform != "all" else list(platform_map.keys()) + platform_keys = [platform_map.get(p, p.capitalize()) for p in platforms] all_results = {} - for p in platforms: + for p, p_key in zip(platforms, platform_keys): captions = [] retry_count = 0 while len(captions) < 3 and retry_count <= MAX_RETRIES: - # Generate captions new_captions = await asyncio.gather( generate_caption(client, prompt, p, "professional", context_from_image), generate_caption(client, prompt, p, "casual", context_from_image), @@ -65,7 +77,6 @@ async def run(data:dict)->dict: regenerate_indices.add(i) regenerate_indices.add(j) - #have non-similar captions, regenerate others captions = [c for idx, c in enumerate(new_captions) if idx not in regenerate_indices] for idx in regenerate_indices: @@ -78,4 +89,36 @@ async def run(data:dict)->dict: captions = new_captions break - return {"result": "", "metadata": {}} + all_results[p_key] ={"variations": captions, "similarities": similarities, "retries": retry_count} + + output_lines = [] + + if context_from_image: + output_lines.append(f"[Image OCR: {context_from_image[:100]}...]") + output_lines.append("") + + for p, platform_data in all_results.items(): + plat_info = PLATFORM_RULES.get(p, {}) + plat_name = plat_info.get("name", p.upper()) + output_lines.append(f"\n**{plat_name}**\n") + output_lines.append(f"Character limit: {plat_info.get('max_length', 'N/A')}") + output_lines.append(f"Hashtags: {plat_info.get('hashtag_count', 'N/A')}") + output_lines.append("") + + variations = platform_data.get("variations", []) + style_names = ["Professional", "Casual", "Bold"] + for i, caption in enumerate(variations): + output_lines.append(f"**{style_names[i]}**") + output_lines.append(caption) + output_lines.append("") + + return {"result": "\n".join(output_lines), + "metadata": { + "platforms":platforms, + "has_ocr": bool(context_from_image), + "captions_data": all_results + } + } + + + From 301546ff6a7b72fc09418862d4f8519f48d6ce81 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 04:04:49 +0530 Subject: [PATCH 08/20] fix: restrict prompt for gen --- .../tools/caption-generator/generator.py | 62 ++++++++++++++++--- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/services/python-tools/tools/caption-generator/generator.py b/services/python-tools/tools/caption-generator/generator.py index 3d56ee4..1c13741 100644 --- a/services/python-tools/tools/caption-generator/generator.py +++ b/services/python-tools/tools/caption-generator/generator.py @@ -3,10 +3,32 @@ from rules import PLATFORM_RULES, VARIATION_STYLES async def extra_text(image_data:str, api_key: str) ->str: + if not image_data: + return "" + client = AsyncOpenAI( base_url="https://api.oxlo.ai/v1", api_key=api_key ) + + try: + response = await client.chat.completions.create( + model="kimi-k2.5", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Extract all visible text from this image. Return only the text content, nothing else."}, + {"type": "image_url", "image_url": {"url": image_data}} + ] + } + ], + max_tokens=1000, + ) + return response.choices[0].message.content or "" + except Exception as e: + print(f"OCR error: {e}") + return "" async def generate_caption( client: AsyncOpenAI, @@ -16,20 +38,40 @@ async def generate_caption( context_from_image: str = "" ) -> str: - platform_info = PLATFORM_RULES.get(platform, PLATFORM_RULES["linkedin"]) + platform_map = { + "x(twitter)": "X(Twitter)", + "instagram": "Instagram", + "linkedin": "Linkedin", + "youtube": "Youtube", + "tiktok": "Tiktok" + } + platform_key = platform_map.get(platform, platform) + platform_info = PLATFORM_RULES.get(platform_key, PLATFORM_RULES["Linkedin"]) style_info = VARIATION_STYLES.get(style, VARIATION_STYLES["professional"]) - enhanced_prompt = f"""Generate a {style} social media caption for {platform_info['name']}. + emoji_guidance = { + "professional": "0-1 emoji only if needed, place at very end", + "casual": "2-3 emojis, end of sentences", + "bold": "3-5 emojis, end of sentences for impact" + } + + max_chars = platform_info['max_length'] + hashtag_count = platform_info['hashtag_count'] + + enhanced_prompt = f"""Write a natural, human-like social media caption for {platform_info['name']}. -Requirements: -- Maximum {platform_info['max_length']} characters -- Use {platform_info['hashtag_count'][0]}-{platform_info['hashtag_count'][1]} relevant hashtags -- Include 1-2 emojis appropriate for the platform -- Add a clear call-to-action from these options: {', '.join(platform_info['cta_patterns'])} -- Style: {style_info['description']} -- Characteristics: {', '.join(style_info['characteristics'])} +Write like a real person, not like an AI. -User's original prompt: {prompt}""" +STRICT REQUIREMENTS: +- Caption (excluding hashtags): MUST be under {max_chars} characters +- Add {hashtag_count[0]}-{hashtag_count[1]} hashtags at the very end on a new line +- NEVER put emojis in middle of words/sentences - ONLY at END of complete sentences or at very end of caption +- Write in natural, conversational way people actually use + +Style ({style_info['name']}): {style_info['description']} +Emoji count: {emoji_guidance.get(style, "1-2 at end")} + +Content: {prompt}""" if context_from_image: enhanced_prompt += f"\n\nAdditional context from image: {context_from_image}" From cd764de2c396fb5618272371fc08ab89e1d2323e Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 04:05:30 +0530 Subject: [PATCH 09/20] fix: update rules for more real captions --- services/python-tools/tools/caption-generator/rules.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/services/python-tools/tools/caption-generator/rules.py b/services/python-tools/tools/caption-generator/rules.py index 2b19319..328e093 100644 --- a/services/python-tools/tools/caption-generator/rules.py +++ b/services/python-tools/tools/caption-generator/rules.py @@ -42,18 +42,15 @@ VARIATION_STYLES = { "professional": { "name": "Professional", - "description": "Formal, polished, business-appropriate tone", - "characteristics": ["Clear and concise", "Professional vocabulary", "Minimal emoji", "Structured"] + "description": "Like sharing work updates on LinkedIn - clear, knowledgeable, barely any emoji", }, "casual": { "name": "Casual", - "description": "Friendly, conversational, approachable tone", - "characteristics": ["Warm language", "Light emoji", "Conversational", "Engaging"] + "description": "Like texting a friend - relaxed, fun, naturally expressive", }, "bold": { "name": "Bold", - "description": "Confident, attention-grabbing, high-impact tone", - "characteristics": ["Power words", "Strong statements", "More emoji", "Call to action"] + "description": "Confident, attention-grabbing, energetic", }, } From 04d1c039858a1d2747088ff6096b2317ca0ecdac Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 04:12:12 +0530 Subject: [PATCH 10/20] feat: add checkpoints to avoid compute --- .../tools/caption-generator/tool.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/services/python-tools/tools/caption-generator/tool.py b/services/python-tools/tools/caption-generator/tool.py index 29b4bd0..99c52d6 100644 --- a/services/python-tools/tools/caption-generator/tool.py +++ b/services/python-tools/tools/caption-generator/tool.py @@ -36,6 +36,21 @@ async def run(data:dict)->dict: "result": None } + words = prompt.strip().split() + if len(words) < 5: + return { + "error": "Please describe your content in more detail. Add more information about what you want to share.", + "result": None + } + + random_patterns = ["asdf", "qwerty", "12345", "abc", "xxx", "yyy", "test", "ffff", "dddd"] + lower_prompt = prompt.lower() + if len(words) < 10 and any(p in lower_prompt for p in random_patterns): + return { + "error": "Please describe your content in more detail. Add more information about what you want to share.", + "result": None + } + client = AsyncOpenAI( base_url="https://api.oxlo.ai/v1", api_key=api_key, @@ -93,9 +108,9 @@ async def run(data:dict)->dict: output_lines = [] - if context_from_image: - output_lines.append(f"[Image OCR: {context_from_image[:100]}...]") - output_lines.append("") + # if context_from_image: + # output_lines.append(f"[Image OCR: {context_from_image[:100]}...]") + # output_lines.append("") for p, platform_data in all_results.items(): plat_info = PLATFORM_RULES.get(p, {}) From 16879967859777895bbf6f1c9c0153f9cc90cd76 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 13:29:05 +0530 Subject: [PATCH 11/20] fix:modify prompt and update view --- services/python-tools/tools/caption-generator/generator.py | 1 + services/python-tools/tools/caption-generator/tool.py | 1 + 2 files changed, 2 insertions(+) diff --git a/services/python-tools/tools/caption-generator/generator.py b/services/python-tools/tools/caption-generator/generator.py index 1c13741..bf0df7e 100644 --- a/services/python-tools/tools/caption-generator/generator.py +++ b/services/python-tools/tools/caption-generator/generator.py @@ -66,6 +66,7 @@ async def generate_caption( - Caption (excluding hashtags): MUST be under {max_chars} characters - Add {hashtag_count[0]}-{hashtag_count[1]} hashtags at the very end on a new line - NEVER put emojis in middle of words/sentences - ONLY at END of complete sentences or at very end of caption +- Do NOT use em dashes (—) in the caption use regular hyphens (-) or no punctuation instead - Write in natural, conversational way people actually use Style ({style_info['name']}): {style_info['description']} diff --git a/services/python-tools/tools/caption-generator/tool.py b/services/python-tools/tools/caption-generator/tool.py index 99c52d6..a9e273e 100644 --- a/services/python-tools/tools/caption-generator/tool.py +++ b/services/python-tools/tools/caption-generator/tool.py @@ -124,6 +124,7 @@ async def run(data:dict)->dict: style_names = ["Professional", "Casual", "Bold"] for i, caption in enumerate(variations): output_lines.append(f"**{style_names[i]}**") + output_lines.append("") output_lines.append(caption) output_lines.append("") From 33090321944577524e37d0f11dfbb9bf941f1b75 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 11 May 2026 17:11:59 +0530 Subject: [PATCH 12/20] fix: preserve index order --- services/python-tools/tools/caption-generator/tool.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/python-tools/tools/caption-generator/tool.py b/services/python-tools/tools/caption-generator/tool.py index a9e273e..ea53704 100644 --- a/services/python-tools/tools/caption-generator/tool.py +++ b/services/python-tools/tools/caption-generator/tool.py @@ -92,12 +92,12 @@ async def run(data:dict)->dict: regenerate_indices.add(i) regenerate_indices.add(j) - captions = [c for idx, c in enumerate(new_captions) if idx not in regenerate_indices] - + new_captions_dict = {i: c for i, c in enumerate(new_captions)} for idx in regenerate_indices: style_map = {0: "professional", 1: "casual", 2: "bold"} new_cap = await generate_caption(client, prompt, p, style_map[idx], context_from_image) - captions.append(new_cap) + new_captions_dict[idx]= new_cap + captions = [new_captions_dict[i] for i in range(3)] retry_count += 1 else: From 0ad35a8c3ee7608c5556e68dd45a672c75ca74dd Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Sun, 17 May 2026 23:26:08 +0530 Subject: [PATCH 13/20] fix: platform rules update --- .../tools/caption-generator/gnerator.py | 45 ----- .../{requirement.txt => requirements.txt} | 0 .../tools/caption-generator/rules.py | 176 +++++++++++++----- 3 files changed, 133 insertions(+), 88 deletions(-) delete mode 100644 services/python-tools/tools/caption-generator/gnerator.py rename services/python-tools/tools/caption-generator/{requirement.txt => requirements.txt} (100%) diff --git a/services/python-tools/tools/caption-generator/gnerator.py b/services/python-tools/tools/caption-generator/gnerator.py deleted file mode 100644 index 3d56ee4..0000000 --- a/services/python-tools/tools/caption-generator/gnerator.py +++ /dev/null @@ -1,45 +0,0 @@ -import asyncio -from openai import AsyncOpenAI -from rules import PLATFORM_RULES, VARIATION_STYLES - -async def extra_text(image_data:str, api_key: str) ->str: - client = AsyncOpenAI( - base_url="https://api.oxlo.ai/v1", - api_key=api_key - ) - -async def generate_caption( - client: AsyncOpenAI, - prompt: str, - platform: str, - style: str, - context_from_image: str = "" -) -> str: - - platform_info = PLATFORM_RULES.get(platform, PLATFORM_RULES["linkedin"]) - style_info = VARIATION_STYLES.get(style, VARIATION_STYLES["professional"]) - - enhanced_prompt = f"""Generate a {style} social media caption for {platform_info['name']}. - -Requirements: -- Maximum {platform_info['max_length']} characters -- Use {platform_info['hashtag_count'][0]}-{platform_info['hashtag_count'][1]} relevant hashtags -- Include 1-2 emojis appropriate for the platform -- Add a clear call-to-action from these options: {', '.join(platform_info['cta_patterns'])} -- Style: {style_info['description']} -- Characteristics: {', '.join(style_info['characteristics'])} - -User's original prompt: {prompt}""" - - if context_from_image: - enhanced_prompt += f"\n\nAdditional context from image: {context_from_image}" - - response = await client.chat.completions.create( - model="kimi-k2.5", - messages=[ - {"role": "user", "content": enhanced_prompt} - ], - max_tokens=500, - ) - - return response.choices[0].message.content \ No newline at end of file diff --git a/services/python-tools/tools/caption-generator/requirement.txt b/services/python-tools/tools/caption-generator/requirements.txt similarity index 100% rename from services/python-tools/tools/caption-generator/requirement.txt rename to services/python-tools/tools/caption-generator/requirements.txt diff --git a/services/python-tools/tools/caption-generator/rules.py b/services/python-tools/tools/caption-generator/rules.py index 328e093..c6cf233 100644 --- a/services/python-tools/tools/caption-generator/rules.py +++ b/services/python-tools/tools/caption-generator/rules.py @@ -1,58 +1,148 @@ -# platform rules and variation for posts +# platform-specific character limits and caption generation rules - -PLATFORM_RULES = { - "X(Twitter)": { - "name": "X (Twitter)", - "max_length": 280, +PLATFORM_LIMITS = { + "youtube": { + "name": "YouTube", + "caption_short_min": 60, + "caption_short_max": 100, + "caption_long_min": 150, + "caption_long_max": 200, + "title_max": 60, + "title_optional": True, + "hashtag_count": (3, 5), + "cta_patterns": ["Like and subscribe", "Let me know in comments", "Share your thoughts", "Don't forget to subscribe"], + "style": "engaging_informative", + "emoji_limit": (1, 3), + }, + "youtube_shorts": { + "name": "YouTube Shorts", + "caption_short_min": 40, + "caption_short_max": 60, + "caption_long_min": 80, + "caption_long_max": 100, + "title_max": 40, + "title_optional": True, "hashtag_count": (2, 3), - "cta_patterns": ["Link in bio", "Click the link", "Learn more", "Read more"], - "emoji_limit": (1, 3) + "cta_patterns": ["Follow for more", "Like if you enjoyed", "Share with friends"], + "style": "punchy_short_form", + "emoji_limit": (1, 2), }, - "Instagram": { + "tiktok": { + "name": "TikTok", + "caption_short_min": 50, + "caption_short_max": 80, + "caption_long_min": 100, + "caption_long_max": 150, + "title_max": 35, + "title_optional": False, + "hashtag_count": (3, 5), + "cta_patterns": ["Follow for more", "Duet this", "Share with friends", "Save this"], + "style": "trending_energetic", + "emoji_limit": (2, 5), + }, + "instagram": { "name": "Instagram", - "max_length": 2200, - "hashtag_count": (5, 8), + "caption_short_min": 80, + "caption_short_max": 100, + "caption_long_min": 125, + "caption_long_max": 150, + "title_max": 0, + "title_optional": False, + "hashtag_count": (3, 5), "cta_patterns": ["Double tap if you agree", "Tag someone", "Share with a friend", "Link in bio"], - "emoji_limit": (3, 6) + "style": "visual_storytelling", + "emoji_limit": (3, 6), + }, + "reddit": { + "name": "Reddit", + "title_short_min": 60, + "title_short_max": 120, + "title_long_min": 120, + "title_long_max": 200, + "caption_short_min": 500, + "caption_short_max": 1000, + "caption_long_min": 1000, + "caption_long_max": 2000, + "hashtag_count": (0, 0), + "cta_patterns": ["What do you think?", "Share your experience", "Comments welcome"], + "style": "authentic_community", + "emoji_limit": (0, 1), }, - "Linkedin": { + "linkedin": { "name": "LinkedIn", - "max_length": 3000, + "caption_short_min": 150, + "caption_short_max": 300, + "caption_long_min": 600, + "caption_long_max": 2000, + "title_max": 0, + "title_optional": False, "hashtag_count": (3, 5), "cta_patterns": ["What are your thoughts?", "Share your experience", "Let's connect", "Comments welcome"], - "emoji_limit": (2, 5) - }, - "Youtube": { - "name": "YouTube", - "max_length": 5000, - "hashtag_count": (3, 5), - "cta_patterns": ["Like and subscribe", "Let me know in comments", "Share your thoughts", "Don't forget to subscribe"], - "emoji_limit": (1, 3) + "style": "professional_thoughtful", + "emoji_limit": (0, 2), }, - "Tiktok": { - "name": "TikTok", - "max_length": 2200, - "hashtag_count": (3, 5), - "cta_patterns": ["Follow for more", "Like and follow", "Duet this", "Share with friends"], - "emoji_limit": (2, 5) + "x_twitter": { + "name": "X (Twitter)", + "caption_short_min": 100, + "caption_short_max": 140, + "caption_long_min": 200, + "caption_long_max": 280, + "title_max": 0, + "title_optional": False, + "hashtag_count": (2, 3), + "cta_patterns": ["Link in bio", "Click the link", "Quote this", "Repost"], + "style": "concise_punchy", + "emoji_limit": (1, 3), }, } -VARIATION_STYLES = { - "professional": { - "name": "Professional", - "description": "Like sharing work updates on LinkedIn - clear, knowledgeable, barely any emoji", - }, - "casual": { - "name": "Casual", - "description": "Like texting a friend - relaxed, fun, naturally expressive", - }, - "bold": { - "name": "Bold", - "description": "Confident, attention-grabbing, energetic", - }, +# Platform mapping for frontend values +PLATFORM_KEYS = { + "youtube": "youtube", + "youtube_shorts": "youtube_shorts", + "tiktok": "tiktok", + "instagram": "instagram", + "reddit": "reddit", + "linkedin": "linkedin", + "x_twitter": "x_twitter", +} + +# Reverse mapping from display names +PLATFORM_REVERSE_MAP = { + "YouTube": "youtube", + "YouTube Shorts": "youtube_shorts", + "TikTok": "tiktok", + "Instagram": "instagram", + "Reddit": "reddit", + "LinkedIn": "linkedin", + "X (Twitter)": "x_twitter", + "X(Twitter)": "x_twitter", } -MAX_RETRIES = 2 -SIMILARITY_THRESHOLD = 0.70 \ No newline at end of file +def get_limits(platform: str, length_type: str) -> dict: + plat = PLATFORM_LIMITS.get(platform, PLATFORM_LIMITS["linkedin"]) + is_short = length_type == "short" + + title_optional = plat.get("title_optional", True) + has_title = (plat.get("title_max", 0) > 0 and title_optional) or "title_short_min" in plat + is_title_only = "title_short_min" in plat + + if is_title_only: + return { + "title_min": plat["title_short_min"] if is_short else plat["title_long_min"], + "title_max": plat["title_short_max"] if is_short else plat["title_long_max"], + "caption_min": plat["caption_short_min"] if is_short else plat["caption_long_min"], + "caption_max": plat["caption_short_max"] if is_short else plat["caption_long_max"], + } + + if has_title: + return { + "caption_min": plat["caption_short_min"] if is_short else plat["caption_long_min"], + "caption_max": plat["caption_short_max"] if is_short else plat["caption_long_max"], + "title_max": plat["title_max"], + } + + return { + "caption_min": plat["caption_short_min"] if is_short else plat["caption_long_min"], + "caption_max": plat["caption_short_max"] if is_short else plat["caption_long_max"], + } \ No newline at end of file From 755cd1e60acaab35a26c126be2f98f1ca8b2ec48 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 18 May 2026 00:21:37 +0530 Subject: [PATCH 14/20] fix: update restricted rules --- .../tools/caption-generator/rules.py | 12 +- .../tools/caption-generator/tool.py | 234 ++++++++++++------ 2 files changed, 158 insertions(+), 88 deletions(-) diff --git a/services/python-tools/tools/caption-generator/rules.py b/services/python-tools/tools/caption-generator/rules.py index c6cf233..280257c 100644 --- a/services/python-tools/tools/caption-generator/rules.py +++ b/services/python-tools/tools/caption-generator/rules.py @@ -3,8 +3,8 @@ PLATFORM_LIMITS = { "youtube": { "name": "YouTube", - "caption_short_min": 60, - "caption_short_max": 100, + "caption_short_min": 80, + "caption_short_max": 150, "caption_long_min": 150, "caption_long_max": 200, "title_max": 60, @@ -16,8 +16,8 @@ }, "youtube_shorts": { "name": "YouTube Shorts", - "caption_short_min": 40, - "caption_short_max": 60, + "caption_short_min": 60, + "caption_short_max": 100, "caption_long_min": 80, "caption_long_max": 100, "title_max": 40, @@ -90,9 +90,9 @@ "title_max": 0, "title_optional": False, "hashtag_count": (2, 3), - "cta_patterns": ["Link in bio", "Click the link", "Quote this", "Repost"], + "cta_patterns": ["Quote this", "Repost", "Share your thoughts"], "style": "concise_punchy", - "emoji_limit": (1, 3), + "emoji_limit": (0, 1), }, } diff --git a/services/python-tools/tools/caption-generator/tool.py b/services/python-tools/tools/caption-generator/tool.py index ea53704..c815156 100644 --- a/services/python-tools/tools/caption-generator/tool.py +++ b/services/python-tools/tools/caption-generator/tool.py @@ -1,43 +1,94 @@ import os +import re from openai import AsyncOpenAI import asyncio -from generator import extra_text, generate_caption -from rules import MAX_RETRIES, SIMILARITY_THRESHOLD, PLATFORM_RULES +from generator import extra_text, generate_all_captions +from rules import PLATFORM_LIMITS, PLATFORM_REVERSE_MAP from helper import check_variations MANIFEST = { "id": "caption-generator", "name": "Social Media Captions", - "description": "Describe your content and get platform-optimized captions with hashtags for Instagram, Twitter/X, LinkedIn, and more.", + "description": "Generate platform-optimized captions with hashtag suggestions for YouTube, TikTok, Instagram, LinkedIn, Reddit, and X/Twitter.", "author": "Oxlo Team", - "version": "2.0.0", + "version": "3.0.0", } -async def run(data:dict)->dict: +MAX_RETRIES = 2 +SIMILARITY_THRESHOLD = 0.70 + + +def normalize_platform(platform: str) -> str: + if not platform: + return "linkedin" + + platform_lower = platform.lower().strip() + + direct_map = { + "youtube": "youtube", + "youtube_shorts": "youtube_shorts", + "youtube shorts": "youtube_shorts", + "tiktok": "tiktok", + "instagram": "instagram", + "reddit": "reddit", + "linkedin": "linkedin", + "x": "x_twitter", + "x_twitter": "x_twitter", + "x(twitter)": "x_twitter", + "twitter": "x_twitter", + } + + if platform_lower in direct_map: + return direct_map[platform_lower] + + if platform in PLATFORM_REVERSE_MAP: + return PLATFORM_REVERSE_MAP[platform] + + for key in PLATFORM_LIMITS.keys(): + if key in platform_lower or platform_lower in key: + return key + + return "linkedin" + + +def validate_length_type(length_type: str) -> str: + if length_type and length_type.lower() in ["short", "long"]: + return length_type.lower() + return "short" + + +async def run(data: dict) -> dict: prompt = data.get("prompt", "") + platform_input = data.get("platform", "linkedin") + length_type_input = data.get("length_type", "short") image_data = data.get("image", "") - platform = data.get("platform", "linkedin") - platform_lower = platform.lower() - if platform_lower == "all platform": - platform = "all" - else: - platform = platform_lower + + import logging + logger = logging.getLogger("caption-generator") + logger.info(f"Received request: platform={platform_input}, length={length_type_input}, prompt_len={len(prompt)}, has_image={bool(image_data)}") + + platform = normalize_platform(platform_input) + length_type = validate_length_type(length_type_input) + api_key = os.getenv("OXLO_API_KEY") if not api_key: + logger.error("No OXLO_API_KEY set") return { "error": "Please enter your Oxlo API key.", "result": None } if not prompt: + logger.error("Empty prompt") return { - "error": "Please enter prompt for captions.", + "error": "Please enter a prompt for captions.", "result": None } words = prompt.strip().split() if len(words) < 5: + logger.error(f"Prompt too short: {len(words)} words") return { "error": "Please describe your content in more detail. Add more information about what you want to share.", "result": None @@ -46,6 +97,7 @@ async def run(data:dict)->dict: random_patterns = ["asdf", "qwerty", "12345", "abc", "xxx", "yyy", "test", "ffff", "dddd"] lower_prompt = prompt.lower() if len(words) < 10 and any(p in lower_prompt for p in random_patterns): + logger.error("Random pattern detected in prompt") return { "error": "Please describe your content in more detail. Add more information about what you want to share.", "result": None @@ -56,85 +108,103 @@ async def run(data:dict)->dict: api_key=api_key, ) - #text from image context_from_image = "" if image_data: + logger.info("Extracting text from image") context_from_image = await extra_text(image_data, api_key) + logger.info(f"Image OCR result: {len(context_from_image)} chars") - platform_map = { - "twitter": "X(Twitter)", - "instagram": "Instagram", - "linkedin": "Linkedin", - "youtube": "Youtube", - "tiktok": "Tiktok" - } - platforms = [platform] if platform != "all" else list(platform_map.keys()) - platform_keys = [platform_map.get(p, p.capitalize()) for p in platforms] - - all_results = {} - for p, p_key in zip(platforms, platform_keys): - captions = [] - retry_count = 0 - while len(captions) < 3 and retry_count <= MAX_RETRIES: - new_captions = await asyncio.gather( - generate_caption(client, prompt, p, "professional", context_from_image), - generate_caption(client, prompt, p, "casual", context_from_image), - generate_caption(client, prompt, p, "bold", context_from_image), - ) - - similarities = check_variations(new_captions) - + plat_info = PLATFORM_LIMITS.get(platform, PLATFORM_LIMITS["linkedin"]) + logger.info(f"Generating captions for {platform} ({length_type})") + + all_results = [] + retry_count = 0 + + while len(all_results) < 3 and retry_count <= MAX_RETRIES: + logger.info(f"Generation attempt {retry_count + 1}") + new_results = await asyncio.gather( + generate_all_captions(client, prompt, platform, length_type, context_from_image), + generate_all_captions(client, prompt, platform, length_type, context_from_image), + generate_all_captions(client, prompt, platform, length_type, context_from_image), + ) + + new_variations = [r.get("variations", []) for r in new_results] + + flat_captions = [] + for variation_set in new_variations: + for v in variation_set: + flat_captions.append(v.get("text", "")) + + if len(flat_captions) >= 3: + similarities = check_variations(flat_captions) high_similarity_pairs = [(i, j, s) for i, j, s in similarities if s > SIMILARITY_THRESHOLD] - + if high_similarity_pairs and retry_count < MAX_RETRIES: - regenerate_indices = set() - for i, j, s in high_similarity_pairs: - regenerate_indices.add(i) - regenerate_indices.add(j) - - new_captions_dict = {i: c for i, c in enumerate(new_captions)} - for idx in regenerate_indices: - style_map = {0: "professional", 1: "casual", 2: "bold"} - new_cap = await generate_caption(client, prompt, p, style_map[idx], context_from_image) - new_captions_dict[idx]= new_cap - captions = [new_captions_dict[i] for i in range(3)] - retry_count += 1 - else: - captions = new_captions - break + continue - all_results[p_key] ={"variations": captions, "similarities": similarities, "retries": retry_count} + all_results = new_results + break + + plat_name = plat_info.get("name", platform.capitalize()) output_lines = [] - - # if context_from_image: - # output_lines.append(f"[Image OCR: {context_from_image[:100]}...]") - # output_lines.append("") - - for p, platform_data in all_results.items(): - plat_info = PLATFORM_RULES.get(p, {}) - plat_name = plat_info.get("name", p.upper()) - output_lines.append(f"\n**{plat_name}**\n") - output_lines.append(f"Character limit: {plat_info.get('max_length', 'N/A')}") - output_lines.append(f"Hashtags: {plat_info.get('hashtag_count', 'N/A')}") - output_lines.append("") - - variations = platform_data.get("variations", []) - style_names = ["Professional", "Casual", "Bold"] - for i, caption in enumerate(variations): - output_lines.append(f"**{style_names[i]}**") - output_lines.append("") - output_lines.append(caption) - output_lines.append("") - return {"result": "\n".join(output_lines), - "metadata": { - "platforms":platforms, - "has_ocr": bool(context_from_image), - "captions_data": all_results - } - } - - + title = None + for r in all_results: + if r.get("title"): + t = r.get("title", "") + t = re.sub(r'\*\*', '', t) + t = re.sub(r'-{2,}', '-', t) + t = re.sub(r'—', '-', t) + t = t.strip() + title = t + break + + variations_output = [] + for result in all_results: + for variation in result.get("variations", []): + text = variation.get("text", "") + if not text: + continue + text = re.sub(r'\*\*', '', text) + text = re.sub(r'-{2,}', '-', text) + text = re.sub(r'—', '-', text) + text = text.replace("\\n", "\n") + text = re.sub(r'^[#*>\s]+', '', text, flags=re.MULTILINE) + text = re.sub(r'\n{3,}', '\n\n', text) + text = text.strip() + if text: + variations_output.append({ + "text": text, + "chars": len(text), + "limit": variation.get("limit", 280), + "title": variation.get("title", ""), + }) + + logger.info(f"Title: {title}") + logger.info(f"Variations generated: {len(variations_output)}") + if variations_output: + logger.info(f"First variation chars: {variations_output[0]['chars']}") + + for i, v in enumerate(variations_output[:3], 1): + if v.get("title"): + output_lines.append(v["title"]) + output_lines.append("") + output_lines.append(f"Variation {i} - Description:") + output_lines.append(v["text"]) + output_lines.append(f"[{v['chars']}/{v['limit']} chars]") + output_lines.append("") + return { + "result": "\n".join(output_lines), + "metadata": { + "platform": platform, + "platform_name": plat_name, + "length_type": length_type, + "variation_type": length_type, + "has_image_context": bool(context_from_image), + }, + "title": title, + "variations": variations_output[:3], + } \ No newline at end of file From d256caf2a43f6e625ded73c35a754a6c051528b0 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 18 May 2026 00:22:52 +0530 Subject: [PATCH 15/20] fix: prompt based generator --- .../tools/caption-generator/generator.py | 306 +++++++++++++++--- 1 file changed, 266 insertions(+), 40 deletions(-) diff --git a/services/python-tools/tools/caption-generator/generator.py b/services/python-tools/tools/caption-generator/generator.py index bf0df7e..d48b03d 100644 --- a/services/python-tools/tools/caption-generator/generator.py +++ b/services/python-tools/tools/caption-generator/generator.py @@ -1,6 +1,16 @@ +import re import asyncio from openai import AsyncOpenAI -from rules import PLATFORM_RULES, VARIATION_STYLES +from rules import PLATFORM_LIMITS, PLATFORM_REVERSE_MAP, get_limits + + +def sanitize_output(text: str) -> str: + text = text.replace("\\n", "\n") + text = re.sub(r'-{2,}', '-', text) + text = re.sub(r'—', '-', text) + text = re.sub(r'\n{3,}', '\n\n', text) + text = text.strip() + return text async def extra_text(image_data:str, api_key: str) ->str: if not image_data: @@ -30,59 +40,275 @@ async def extra_text(image_data:str, api_key: str) ->str: print(f"OCR error: {e}") return "" -async def generate_caption( - client: AsyncOpenAI, - prompt: str, - platform: str, - style: str, - context_from_image: str = "" -) -> str: - - platform_map = { - "x(twitter)": "X(Twitter)", - "instagram": "Instagram", - "linkedin": "Linkedin", - "youtube": "Youtube", - "tiktok": "Tiktok" +def build_prompt(platform: str,length_type: str, prompt: str, context_from_image: str = "") -> str: + plat = PLATFORM_LIMITS.get(platform, PLATFORM_LIMITS["linkedin"]) + limits = get_limits(platform, length_type) + + is_short = length_type == "short" + is_reddit = platform == "reddit" + title_optional = plat.get("title_optional", True) + has_title = (plat.get("title_max", 0) > 0 and title_optional) or "title_short_min" in plat + + style_guides = { + "engaging_informative": "engaging, informative, YouTube-friendly. Hook viewers in the first line. Clear and conversational.", + "punchy_short_form": "punchy, fast-paced, hook-first. Perfect for short attention spans. Bold and energetic.", + "trending_energetic": "trending, energetic, TikTok-native. Use popular phrases naturally. Fun and relatable.", + "visual_storytelling": "visual-friendly, storytelling-focused. Complement the image/video. Emotional and engaging.", + "authentic_community": "authentic, community-focused, Reddit-native. No clickbait. Honest and direct.", + "professional_thoughtful": "professional, thought-provoking, LinkedIn-appropriate. No emojis or minimal. Value-driven.", + "concise_punchy": "concise, punchy, hook-first. Every word counts. Bold and direct.", } - platform_key = platform_map.get(platform, platform) - platform_info = PLATFORM_RULES.get(platform_key, PLATFORM_RULES["Linkedin"]) - style_info = VARIATION_STYLES.get(style, VARIATION_STYLES["professional"]) - - emoji_guidance = { - "professional": "0-1 emoji only if needed, place at very end", - "casual": "2-3 emojis, end of sentences", - "bold": "3-5 emojis, end of sentences for impact" + style_guide = style_guides.get(plat.get("style", "concise_punchy"), "concise and engaging") + + emoji_count = plat.get("emoji_limit", (1, 3)) + emoji_guide = f"Use {emoji_count[0]}-{emoji_count[1]} emojis, placed at the end of sentences or at the very end of the caption." + if plat.get("style") == "professional_thoughtful": + emoji_guide = "Use 0-1 emoji only if truly needed, or skip emojis entirely." + + cta_patterns = plat.get("cta_patterns", []) + cta_text = ", ".join(cta_patterns[:3]) + + hashtag_count = plat.get("hashtag_count", (3, 5)) + if hashtag_count[1] == 0: + hashtag_text = "Do NOT use any hashtags." + else: + hashtag_text = f"Add {hashtag_count[0]}-{hashtag_count[1]} relevant hashtags at the end on a new line." + + word_limits = { + "youtube": {"short": 30, "long": 60}, + "youtube_shorts": {"short": 15, "long": 25}, + "tiktok": {"short": 15, "long": 25}, + "instagram": {"short": 15, "long": 25}, + "reddit": {"short": 100, "long": 200}, + "linkedin": {"short": 100, "long": 200}, + "x_twitter": {"short": 50, "long": 100} } + max_words = word_limits.get(platform, {}).get(length_type, 25) + + if is_reddit: + title_min = limits.get("title_min", 50) + title_max = limits.get("title_max", 120) + caption_min = limits.get("caption_min", 500) + caption_max = limits.get("caption_max", 1000) + + prompt_text = f"""Create 3 Reddit posts with different titles. + +Write 3 posts: +Title 1: [title {title_min}-{title_max} chars] +Description 1: [post {caption_min}-{caption_max} chars, {max_words} words max] + +Title 2: [different title] +Description 2: [different post] + +Title 3: [different title] +Description 3: [different post] + +Rules: +- All 3 titles must use different words +- Use line breaks in body +- End each with a question +- {emoji_guide} + +Topic: {prompt}""" + + if context_from_image: + prompt_text += f"\n\nAdditional context from image: {context_from_image}" + + return prompt_text - max_chars = platform_info['max_length'] - hashtag_count = platform_info['hashtag_count'] + elif has_title: + title_max_val = plat.get("title_max", 60) + caption_min = limits.get("caption_min", 60) + caption_max = limits.get("caption_max", 100) + + prompt_text = f"""Create 3 YouTube video captions with different titles. + +Write 3 posts: +Title 1: [title max {title_max_val} chars] +Description 1: [caption {caption_min}-{caption_max} chars, {max_words} words max - make it detailed and engaging] + +Title 2: [different title] +Description 2: [different caption] + +Title 3: [different title] +Description 3: [different caption] + +Rules: +- All 3 titles must use different words/angles +- Don't repeat same title words +- Descriptions should be {caption_min}-{caption_max} characters - write close to the max +- {hashtag_text} +- {cta_text} +- {emoji_guide} + +Topic: {prompt}""" + + if context_from_image: + prompt_text += f"\n\nAdditional context from image: {context_from_image}" + + return prompt_text - enhanced_prompt = f"""Write a natural, human-like social media caption for {platform_info['name']}. + else: + caption_min = limits.get("caption_min", 100) + caption_max = limits.get("caption_max", 280) + + prompt_text = f"""Write a natural, human-like social media caption. -Write like a real person, not like an AI. +Platform: {plat['name']} +Style: {style_guide} -STRICT REQUIREMENTS: -- Caption (excluding hashtags): MUST be under {max_chars} characters -- Add {hashtag_count[0]}-{hashtag_count[1]} hashtags at the very end on a new line -- NEVER put emojis in middle of words/sentences - ONLY at END of complete sentences or at very end of caption -- Do NOT use em dashes (—) in the caption use regular hyphens (-) or no punctuation instead -- Write in natural, conversational way people actually use +Write EXACTLY {caption_min}-{caption_max} characters. -Style ({style_info['name']}): {style_info['description']} -Emoji count: {emoji_guidance.get(style, "1-2 at end")} +Requirements: +- Hook viewers in the first line - this is the most important part +- Be engaging, {style_guide} +- {hashtag_text} +- {cta_text} +- {emoji_guide} +- Do NOT use double dashes (--) or em dashes (---) - use a single hyphen (-) instead +- Write like a real person, not like an AI +- MAXIMUM {max_words} WORDS -Content: {prompt}""" +User's topic: {prompt}""" + + if context_from_image: + prompt_text += f"\n\nAdditional context from image: {context_from_image}" + + return prompt_text - if context_from_image: - enhanced_prompt += f"\n\nAdditional context from image: {context_from_image}" +async def generate_caption( + client: AsyncOpenAI, + prompt: str, + platform: str, + length_type: str, + context_from_image: str = "" +) -> str: + enhanced_prompt = build_prompt(platform, length_type, prompt, context_from_image) + + plat = PLATFORM_LIMITS.get(platform, PLATFORM_LIMITS["linkedin"]) + limits = get_limits(platform, length_type) + max_tokens = min(limits.get("caption_max", 280) // 4, 500) + response = await client.chat.completions.create( model="kimi-k2.5", messages=[ + { + "role": "system", + "content": f"You are a social media caption writing assistant. Return ONLY the caption text - no explanations, no markdown formatting, no extra text. CRITICAL: The caption must be EXACTLY between {limits.get('caption_min', 50)} and {limits.get('caption_max', 100)} characters. NEVER exceed {limits.get('caption_max', 100)} characters. Do not use double dashes (--) or em dashes (---). Use a single hyphen (-) instead." + }, {"role": "user", "content": enhanced_prompt} ], - max_tokens=500, + temperature=0.7, + max_tokens=max_tokens, + ) + + return response.choices[0].message.content or "" + + +async def generate_all_captions( + client: AsyncOpenAI, + prompt: str, + platform: str, + length_type: str, + context_from_image: str = "" +) -> dict: + plat = PLATFORM_LIMITS.get(platform, PLATFORM_LIMITS["linkedin"]) + limits = get_limits(platform, length_type) + is_reddit = platform == "reddit" + + variations = await asyncio.gather( + generate_caption(client, prompt, platform, length_type, context_from_image), + generate_caption(client, prompt, platform, length_type, context_from_image), + generate_caption(client, prompt, platform, length_type, context_from_image), ) + + results = [] + titles = [] + title_optional = plat.get("title_optional", True) + has_title = (plat.get("title_max", 0) > 0 and title_optional) or "title_short_min" in plat + + all_variations_text = [] + for variation in variations: + if variation: + text = sanitize_output(variation.strip()) + parts = re.split(r'(?:Title\s*\d*:)|(?:Description\s*\d*:)|(?:Option\s*[123]:)|(?:\d+\.)|(?:---)', text, flags=re.IGNORECASE) + found_parts = [p.strip() for p in parts if p.strip() and len(p.strip()) > 10] + if len(found_parts) >= 3: + all_variations_text.extend(found_parts[:3]) + else: + all_variations_text.append(text) - return response.choices[0].message.content \ No newline at end of file + for text in all_variations_text[:3]: + var_title = None + + if has_title or is_reddit: + lines = [l.strip() for l in text.split("\n") if l.strip()] + + title_pattern = re.search(r'(?:Title\s*\d*[\s:]*)', text, re.IGNORECASE) + if title_pattern: + start = title_pattern.end() + remaining = text[start:].strip() + newline_pos = remaining.find("\n") + if newline_pos > 0: + potential_title = remaining[:newline_pos].strip() + else: + potential_title = remaining.strip() + + title_limit = plat.get("title_max", 60) + if is_reddit: + title_limit = limits.get("title_max", 120) + if potential_title and len(potential_title) <= title_limit: + var_title = potential_title + after_title = remaining[newline_pos:] if newline_pos > 0 else "" + text = after_title.strip() + + if not var_title and lines: + first_line = lines[0] + title_limit = plat.get("title_max", 60) + if is_reddit: + title_limit = limits.get("title_max", 120) + if len(first_line) <= title_limit and not first_line.startswith("#"): + var_title = first_line + text = " ".join(lines[1:]) if len(lines) > 1 else "" + + text = re.sub(r'^(?:Description\s*\d*:)\s*', '', text, flags=re.IGNORECASE).strip() + + caption_text = text + else: + caption_text = text + + caption_limit = limits.get("caption_max", 280) + + if len(caption_text) > caption_limit: + caption_text = caption_text[:caption_limit] + last_space = caption_text.rfind(" ") + if last_space > 0: + caption_text = caption_text[:last_space] + caption_text = caption_text.strip() + + if len(caption_text) > caption_limit: + caption_text = caption_text[:caption_limit] + + results.append({ + "text": caption_text, + "chars": len(caption_text), + "limit": caption_limit, + "title": var_title, + }) + if var_title: + titles.append(var_title) + + for i, r in enumerate(results): + if i < len(titles): + r["title"] = titles[i] + + main_title = titles[0] if titles else None + + return { + "title": main_title, + "titles": titles, + "variation_type": length_type, + "platform": plat.get("name", platform), + "variations": results, + } \ No newline at end of file From b7af09ed01559ad08ae75dbc4aacaf73cd5887fa Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Mon, 18 May 2026 00:24:48 +0530 Subject: [PATCH 16/20] feat(ui): handles diff variations --- app/src/app/tools/[toolId]/page.tsx | 284 ++++++++++++++++++++++++- app/src/lib/tools/caption-generator.ts | 51 +++-- 2 files changed, 303 insertions(+), 32 deletions(-) diff --git a/app/src/app/tools/[toolId]/page.tsx b/app/src/app/tools/[toolId]/page.tsx index 154a99e..e7368fb 100644 --- a/app/src/app/tools/[toolId]/page.tsx +++ b/app/src/app/tools/[toolId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { Button, Label, Textarea } from "@ansospace/ui"; -import { ArrowUpRight, Crown, Lock, Play, X } from "lucide-react"; +import { ArrowUpRight, Check, Copy, Crown, Lock, Play, X } from "lucide-react"; import { notFound, useParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { CodeEditor } from "@/components/code-editor"; @@ -31,8 +31,116 @@ export default function DynamicToolPage() { return ; } +function VariationCopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(async () => { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [text]); + + return ( + + ); +} + +function CaptionResultDisplay({ + variations, + title, + platformName, + lengthType, +}: { + variations?: { text: string; chars: number; limit: number; title?: string }[]; + title?: string | null; + platformName?: string; + lengthType?: string; +}) { + if (!variations || variations.length === 0) return null; + + const getBarColor = (ratio: number) => { + if (ratio > 1.0) return "bg-red-500"; + if (ratio > 0.8) return "bg-amber-500"; + return "bg-green-500"; + }; + + const getTextColor = (ratio: number) => { + if (ratio > 1.0) return "text-red-500"; + if (ratio > 0.8) return "text-amber-500"; + return "text-green-500"; + }; + + return ( +
+ {platformName && ( +
+

{platformName}

+ + {lengthType === "short" ? "Short" : "Long"} + +
+ )} + + {variations.map((v, i) => { + const varTitle = v.title || title; + const copyText = varTitle ? "Title: " + varTitle + "\n\nCaption: " + v.text : v.text; + const charRatio = v.chars / v.limit; + const barColor = getBarColor(charRatio); + const barWidth = Math.min(charRatio * 100, 100); + const textColor = getTextColor(charRatio); + + return ( +
+ {varTitle && ( +
+
+
+

Title {i + 1}

+

{varTitle}

+
+ +
+
+ )} + +
+

+ Variation {i + 1} +

+
+

+ {v.text} +

+
+
+
+
+
+ + {v.chars}/{v.limit} + +
+
+ +
+
+
+ ); + })} +
+ ); +} + function ToolPageContent({ toolId }: { toolId: string }) { const tool = getToolById(toolId)!; + const isCaptionGenerator = tool.id === "caption-generator"; // Model is hardcoded per tool - no user selection const model = tool.defaultModel || "llama-3.3-70b"; const [fields, setFields] = useState>(() => { @@ -44,6 +152,19 @@ function ToolPageContent({ toolId }: { toolId: string }) { return initial; }); + // Caption-specific result data + const [captionResult, setCaptionResult] = useState<{ + variations?: { text: string; chars: number; limit: number; title?: string }[]; + title?: string | null; + platformName?: string; + lengthType?: string; + }>({}); + + // Length selector modal state + const [showLengthModal, setShowLengthModal] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [apiError, setApiError] = useState(null); + // Tier2 tools MUST bypass Next.js proxy buffering to prevent silent timeouts on long executions const runnerUrl = process.env.NEXT_PUBLIC_TOOL_RUNNER_URL || "http://localhost:9080"; const apiBase = tool.tier === "tier2" ? `${runnerUrl}/api/tools` : "/api/tools"; @@ -64,6 +185,7 @@ function ToolPageContent({ toolId }: { toolId: string }) { } setFields(restored); setResult(restoredResult); + setCaptionResult({}); }, [tool, setResult] ); @@ -76,30 +198,103 @@ function ToolPageContent({ toolId }: { toolId: string }) { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); - // Show popup when limit is newly reached +// Show popup when limit is newly reached useEffect(() => { if (mounted && toolUsage.limitReached) { setShowUpgradeDialog(true); } }, [mounted, toolUsage.limitReached]); + // Parse caption results when result changes + useEffect(() => { + if (isCaptionGenerator && result) { + try { + const data = JSON.parse(result); + if (data.variations || data.title) { + setCaptionResult({ + variations: data.variations, + title: data.title, + platformName: data.metadata?.platform_name || data.metadata?.platform, + lengthType: data.metadata?.length_type, + }); + } + } catch { + // Not JSON, ignore + } + } + }, [result, isCaptionGenerator]); + + // Check if all required fields are filled + const isReady = tool.requiredFields.every((field) => fields[field]?.trim()); + const handleExecute = () => { - // Check per-tool usage limit if (!canExecute(tool.id)) { setShowUpgradeDialog(true); return; } - // Check required fields for (const field of tool.requiredFields) { if (!fields[field]?.trim()) return; } - execute({ ...fields, model }); - // Track usage for THIS tool - trackExecution(tool.id); + + if (isCaptionGenerator) { + setShowLengthModal(true); + } else { + execute({ ...fields, model }); + trackExecution(tool.id); + } }; - // Check if all required fields are filled - const isReady = tool.requiredFields.every((field) => fields[field]?.trim()); + const doExecute = useCallback( + async (lengthType: string) => { + setCaptionResult({}); + setResult(""); + setApiError(null); + setIsGenerating(true); + setShowLengthModal(false); + + const executeBody = { + ...fields, + model, + length_type: lengthType, + }; + + try { + const customApiKey = localStorage.getItem("oxloApiKey"); + const headers: Record = { "Content-Type": "application/json" }; + if (customApiKey) { + headers["x-api-key"] = customApiKey; + } + + const response = await fetch(`${apiBase}/${tool.id}`, { + method: "POST", + headers, + body: JSON.stringify(executeBody), + }); + + const data = await response.json(); + + if (data.error) { + setApiError(data.error); + } else if (data.variations || data.title) { + setCaptionResult({ + variations: data.variations, + title: data.title, + platformName: data.metadata?.platform_name || data.metadata?.platform, + lengthType: data.metadata?.length_type || lengthType, + }); + setResult(data.result || ""); + } else { + setResult(data.result || JSON.stringify(data)); + } + } catch (err) { + setApiError(err instanceof Error ? err.message : "Request failed"); + } + + setIsGenerating(false); + trackExecution(tool.id); + }, + [fields, model, apiBase, tool.id, setResult, trackExecution] + ); return ( <> @@ -202,11 +397,80 @@ function ToolPageContent({ toolId }: { toolId: string }) { {/* Results */}
- + {apiError && !isGenerating && ( +
+

{apiError}

+ {apiError.includes("API key") && ( +

+ Set your Oxlo API key in Settings to use this tool. +

+ )} +
+ )} + {isGenerating && !result && !captionResult.variations && !apiError && ( +
+
+
+ Generating captions... +
+
+ )} + {isCaptionGenerator && (captionResult.variations || captionResult.title) ? ( + + ) : ( + !isGenerating && + )}
+ {/* length Selector */} + {showLengthModal && ( +
+
+ + +
+ +
+ +

Caption Length

+

+ How long do you want your captions to be? +

+ +
+ + +
+
+
+ )} + {/* ─── Upgrade Dialog Popup ──────────────────────────── */} {showUpgradeDialog && (
diff --git a/app/src/lib/tools/caption-generator.ts b/app/src/lib/tools/caption-generator.ts index e0663ca..c0d05f7 100644 --- a/app/src/lib/tools/caption-generator.ts +++ b/app/src/lib/tools/caption-generator.ts @@ -12,28 +12,35 @@ export const captionGenerator: ToolDefinition = { requiredFields: ["prompt", "platform"], defaultModel: "kimi-k2.5", buildSystemPrompt: () => "", - buildUserPrompt: () => "", + buildUserPrompt: () => "", inputs: [ - { key: "prompt", label: "Prompt", type: "textarea", rows: 4, placeholder: "E.g. 'We just launched our AI-powered developer tools platform. It has 22+ free tools for debugging, testing, and code generation.'"}, - { key: "platform", label: "Platform", type: "select", - options: [ - { value: "All platform", label: "All Platforms" }, - { value: "Instagram", label: "Instagram" }, - { value: "X(Twitter)", label: "X (Twitter)" }, - { value: "Linkedin", label: "LinkedIn" }, - { value: "Youtube", label: "YouTube" }, - { value: "Tiktok", label: "TikTok" }, - ] }, - - // { key: "style", label: "Style", type: "select", - // options: [ - // { value: "professional", label: "Professional" }, - // { value: "casual", label: "Casual" }, - // { value: "bold", label: "Bold" }, - // ], - // defaultValue: "professional" }, - - { key: "image", label: "Image (optional)", type: "image", } - ] + { + key: "platform", + label: "Platform", + type: "select", + options: [ + { value: "youtube", label: "YouTube" }, + { value: "youtube_shorts", label: "YouTube Shorts" }, + { value: "tiktok", label: "TikTok" }, + { value: "instagram", label: "Instagram" }, + { value: "reddit", label: "Reddit" }, + { value: "linkedin", label: "LinkedIn" }, + { value: "x_twitter", label: "X (Twitter)" }, + ], + }, + { + key: "prompt", + label: "Caption Prompt", + type: "textarea", + rows: 4, + placeholder: + "E.g. 'We just launched our AI-powered developer tools platform. It has 22+ free tools for debugging, testing, and code generation.'", + }, + { + key: "image", + label: "Image (optional)", + type: "image", + }, + ], }; \ No newline at end of file From 78923c655347018a3b312246f917633e8931c8f1 Mon Sep 17 00:00:00 2001 From: 11PRIMUS Date: Tue, 19 May 2026 20:32:13 +0530 Subject: [PATCH 17/20] fix(ui): image placeholder --- app/src/app/tools/[toolId]/page.tsx | 113 ++++++++++++++++++++++--- app/src/lib/tools/caption-generator.ts | 7 +- 2 files changed, 104 insertions(+), 16 deletions(-) diff --git a/app/src/app/tools/[toolId]/page.tsx b/app/src/app/tools/[toolId]/page.tsx index e7368fb..a03e66f 100644 --- a/app/src/app/tools/[toolId]/page.tsx +++ b/app/src/app/tools/[toolId]/page.tsx @@ -1,9 +1,9 @@ "use client"; import { Button, Label, Textarea } from "@ansospace/ui"; -import { ArrowUpRight, Check, Copy, Crown, Lock, Play, X } from "lucide-react"; +import { ArrowUpRight, Check, Copy, Crown, Lock, Play, Plus, X } from "lucide-react"; import { notFound, useParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { CodeEditor } from "@/components/code-editor"; import { ResultViewer } from "@/components/result-viewer"; import { ToolLayout } from "@/components/tool-layout"; @@ -312,6 +312,13 @@ function ToolPageContent({ toolId }: { toolId: string }) { config={input} value={fields[input.key] || ""} onChange={(value) => setField(input.key, value)} + {...(isCaptionGenerator && input.type === "textarea" + ? { + onAttach: (_, dataUrl) => setField("image", dataUrl), + attachedImage: fields.image, + onRemoveImage: () => setField("image", ""), + } + : {})} /> ))} @@ -561,10 +568,16 @@ function InputField({ config, value, onChange, + onAttach, + attachedImage, + onRemoveImage, }: { config: InputFieldConfig; value: string; onChange: (value: string) => void; + onAttach?: (key: string, dataUrl: string) => void; + attachedImage?: string; + onRemoveImage?: () => void; }) { switch (config.type) { case "code": @@ -580,19 +593,99 @@ function InputField({
); - case "textarea": + case "textarea": { + const fileRef = useRef(null); + const [showPreview, setShowPreview] = useState(false); + const [spinning, setSpinning] = useState(false); + const hasImage = onAttach && attachedImage; return (
-