From e2f2d79d4cd6eb9edbfcb9602e4a08deb10efbe9 Mon Sep 17 00:00:00 2001 From: Desktop Date: Mon, 10 Nov 2025 22:16:02 +0500 Subject: [PATCH] Implement V2 AI functions and enhance progress handling - Added support for new V2 functions: `auto_cluster_v2` and `generate_ideas_v2`, including backend logic and API endpoints. - Updated model configuration to ensure V2 functions validate the presence of models before execution. - Enhanced progress modal to provide better feedback during asynchronous tasks, including task IDs for debugging. - Updated frontend components to integrate new V2 functionalities and improve user experience with clustering and idea generation. --- backend/igny8_core/ai/engine.py | 18 +- .../workflow_functions/auto_cluster_v2.py | 188 +++++++++++++++ .../workflow_functions/generate_ideas_v2.py | 152 ++++++++++++ backend/igny8_core/ai/helpers/settings.py | 31 ++- backend/igny8_core/ai/registry.py | 12 + backend/igny8_core/modules/planner/views.py | 223 ++++++++++++++++++ .../src/components/common/AIProgressModal.tsx | 27 +-- .../src/config/pages/table-actions.config.tsx | 12 + frontend/src/hooks/useProgressModal.ts | 134 ++++++++--- frontend/src/pages/Planner/Clusters.tsx | 17 ++ frontend/src/pages/Planner/Keywords.tsx | 30 +++ frontend/src/services/api.ts | 134 +++++++++++ 12 files changed, 920 insertions(+), 58 deletions(-) create mode 100644 backend/igny8_core/ai/functions/workflow_functions/auto_cluster_v2.py create mode 100644 backend/igny8_core/ai/functions/workflow_functions/generate_ideas_v2.py diff --git a/backend/igny8_core/ai/engine.py b/backend/igny8_core/ai/engine.py index ab8fc7b1..e6809892 100644 --- a/backend/igny8_core/ai/engine.py +++ b/backend/igny8_core/ai/engine.py @@ -82,10 +82,8 @@ class AIEngine: ai_core = AICore(account=self.account) function_name = fn.get_name() - # Generate function_id for tracking (ai-{function_name}-01) - # Normalize underscores to hyphens to match frontend tracking IDs - function_id_base = function_name.replace('_', '-') - function_id = f"ai-{function_id_base}-01-desktop" + # Generate function_id for tracking (ai_{function_name}) + function_id = f"ai_{function_name}" # Get model config from settings (Stage 4 requirement) # Pass account to read model from IntegrationSettings @@ -111,6 +109,18 @@ class AIEngine: exc_info=True, ) + # For V2 functions: Validate model exists - no default, only execute if model is present + if function_name.endswith('_v2'): + if not model_from_integration or not model: + error_msg = "AI model not configured. Please configure OpenAI model in Integration settings." + self.console_tracker.error('ModelError', error_msg) + self.step_tracker.add_request_step("PREP", "error", error_msg) + self.tracker.error(error_msg, meta=self.step_tracker.get_meta()) + return { + 'success': False, + 'error': error_msg + } + # Track configured model information so it shows in the progress modal self.step_tracker.add_request_step( "PREP", diff --git a/backend/igny8_core/ai/functions/workflow_functions/auto_cluster_v2.py b/backend/igny8_core/ai/functions/workflow_functions/auto_cluster_v2.py new file mode 100644 index 00000000..6c3a7c47 --- /dev/null +++ b/backend/igny8_core/ai/functions/workflow_functions/auto_cluster_v2.py @@ -0,0 +1,188 @@ +""" +Auto Cluster Keywords V2 - Workflow Function +Uses helpers folder imports and dynamic model loading +Max 50 keywords for bulk actions +""" +import logging +from typing import Dict, List, Any +from django.db import transaction +from igny8_core.ai.helpers.base import BaseAIFunction +from igny8_core.modules.planner.models import Keywords, Clusters +from igny8_core.ai.helpers.ai_core import AICore +from igny8_core.ai.prompts import PromptRegistry +from igny8_core.ai.helpers.settings import get_model_config + +logger = logging.getLogger(__name__) + + +class AutoClusterV2Function(BaseAIFunction): + """Auto-cluster keywords using AI - V2 with dynamic model""" + + def get_name(self) -> str: + return 'auto_cluster_v2' + + def get_metadata(self) -> Dict: + return { + 'display_name': 'Keywords Clustering', + 'description': 'Group related keywords into semantic clusters', + 'phases': { + 'INIT': 'Validating keywords...', + 'PREP': 'Loading keyword data...', + 'AI_CALL': 'Analyzing relationships with AI...', + 'PARSE': 'Processing cluster results...', + 'SAVE': 'Creating clusters...', + 'DONE': 'Clustering completed successfully' + } + } + + def get_max_items(self) -> int: + return 50 # Max 50 keywords + + def validate(self, payload: dict, account=None) -> Dict: + """Validate input with max 50 keywords""" + ids = payload.get('ids', []) + if not ids: + return {'valid': False, 'error': 'No keywords selected'} + + if len(ids) > 50: + return {'valid': False, 'error': 'Maximum 50 keywords allowed for clustering'} + + # Check keywords exist + queryset = Keywords.objects.filter(id__in=ids) + if account: + queryset = queryset.filter(account=account) + keywords = queryset + + if keywords.count() != len(ids): + return {'valid': False, 'error': 'Some selected keywords not found'} + + return {'valid': True} + + def prepare(self, payload: dict, account=None) -> Dict: + """Load keywords with relationships""" + ids = payload.get('ids', []) + sector_id = payload.get('sector_id') + + queryset = Keywords.objects.filter(id__in=ids) + if account: + queryset = queryset.filter(account=account) + if sector_id: + queryset = queryset.filter(sector_id=sector_id) + + keywords = list(queryset.select_related('seed_keyword', 'cluster', 'account', 'site', 'sector')) + + if not keywords: + raise ValueError("No keywords found") + + keyword_data = [] + for kw in keywords: + keyword_data.append({ + 'id': kw.id, + 'keyword': kw.keyword, + 'volume': kw.volume, + 'difficulty': kw.difficulty, + 'intent': kw.seed_keyword.intent if kw.seed_keyword else None, + }) + + return { + 'keywords': keywords, # Store original objects + 'keyword_data': keyword_data, + 'sector_id': sector_id + } + + def build_prompt(self, data: Dict, account=None) -> str: + """Build clustering prompt""" + keyword_data = data.get('keyword_data', []) + sector_id = data.get('sector_id') + + # Format keywords + keywords_text = '\n'.join([ + f"- {kw['keyword']} (Volume: {kw['volume']}, Difficulty: {kw['difficulty']}, Intent: {kw.get('intent', 'N/A')})" + for kw in keyword_data + ]) + + # Build context + context = {'KEYWORDS': keywords_text} + + # Add sector context if available + if sector_id: + try: + from igny8_core.auth.models import Sector + sector = Sector.objects.get(id=sector_id) + if sector: + context['SECTOR'] = sector.name + except Exception: + pass + + # Get prompt from registry + prompt = PromptRegistry.get_prompt( + function_name='auto_cluster', + account=account, + context=context + ) + + # Ensure JSON format instruction + prompt_lower = prompt.lower() + has_json_request = ( + 'json' in prompt_lower and + ('format' in prompt_lower or 'respond' in prompt_lower or 'return' in prompt_lower or 'output' in prompt_lower) + ) + + if not has_json_request: + prompt += "\n\nIMPORTANT: You must respond with valid JSON only. The response must be a JSON object with a 'clusters' array." + + return prompt + + def parse_response(self, response: str, step_tracker=None) -> List[Dict]: + """Parse AI response into cluster data""" + if not response or not response.strip(): + raise ValueError("Empty response from AI") + + ai_core = AICore(account=getattr(self, 'account', None)) + json_data = ai_core.extract_json(response) + + if not json_data or 'clusters' not in json_data: + raise ValueError("Invalid response format: missing 'clusters' array") + + return json_data['clusters'] + + def save_output(self, parsed: List[Dict], original_data: Any, account=None, step_tracker=None) -> Dict: + """Save clusters and update keywords""" + keywords = original_data.get('keywords', []) + keyword_map = {kw.id: kw for kw in keywords} + + clusters_created = 0 + keywords_updated = 0 + + with transaction.atomic(): + for cluster_data in parsed: + cluster_name = cluster_data.get('name', 'Unnamed Cluster') + cluster_keywords = cluster_data.get('keywords', []) + + if not cluster_keywords: + continue + + # Create cluster + cluster = Clusters.objects.create( + name=cluster_name, + description=f"Auto-clustered from {len(cluster_keywords)} keywords", + account=account, + status='active' + ) + clusters_created += 1 + + # Update keywords + for keyword_text in cluster_keywords: + for kw in keywords: + if kw.keyword.lower() == keyword_text.lower(): + kw.cluster = cluster + kw.save() + keywords_updated += 1 + break + + return { + 'clusters_created': clusters_created, + 'keywords_updated': keywords_updated, + 'count': clusters_created + } + diff --git a/backend/igny8_core/ai/functions/workflow_functions/generate_ideas_v2.py b/backend/igny8_core/ai/functions/workflow_functions/generate_ideas_v2.py new file mode 100644 index 00000000..6096cab1 --- /dev/null +++ b/backend/igny8_core/ai/functions/workflow_functions/generate_ideas_v2.py @@ -0,0 +1,152 @@ +""" +Generate Ideas V2 - Workflow Function +Single cluster only, uses helpers folder imports +""" +import logging +from typing import Dict, List, Any +from django.db import transaction +from igny8_core.ai.helpers.base import BaseAIFunction +from igny8_core.modules.planner.models import Clusters, ContentIdeas, Keywords +from igny8_core.ai.helpers.ai_core import AICore +from igny8_core.ai.prompts import PromptRegistry +from igny8_core.ai.helpers.settings import get_model_config + +logger = logging.getLogger(__name__) + + +class GenerateIdeasV2Function(BaseAIFunction): + """Generate content ideas from cluster - V2 with dynamic model""" + + def get_name(self) -> str: + return 'generate_ideas_v2' + + def get_metadata(self) -> Dict: + return { + 'display_name': 'Generate Ideas', + 'description': 'Generate SEO-optimized content ideas from keyword cluster', + 'phases': { + 'INIT': 'Validating cluster...', + 'PREP': 'Loading cluster data...', + 'AI_CALL': 'Generating ideas with AI...', + 'PARSE': 'Processing idea results...', + 'SAVE': 'Saving ideas...', + 'DONE': 'Ideas generated successfully' + } + } + + def get_max_items(self) -> int: + return 1 # Single cluster only + + def validate(self, payload: dict, account=None) -> Dict: + """Validate single cluster""" + ids = payload.get('ids', []) + if not ids: + return {'valid': False, 'error': 'No cluster selected'} + + if len(ids) > 1: + return {'valid': False, 'error': 'Only one cluster can be processed at a time'} + + queryset = Clusters.objects.filter(id=ids[0]) + if account: + queryset = queryset.filter(account=account) + cluster = queryset.first() + + if not cluster: + return {'valid': False, 'error': 'Cluster not found'} + + return {'valid': True} + + def prepare(self, payload: dict, account=None) -> Dict: + """Load cluster with keywords""" + cluster_id = payload.get('ids', [])[0] + queryset = Clusters.objects.filter(id=cluster_id) + if account: + queryset = queryset.filter(account=account) + + cluster = queryset.prefetch_related('keywords__seed_keyword').first() + + if not cluster: + raise ValueError("Cluster not found") + + # Get keywords + keyword_objects = Keywords.objects.filter(cluster=cluster).select_related('seed_keyword') + keywords = [] + for kw in keyword_objects: + keywords.append({ + 'keyword': kw.seed_keyword.keyword if kw.seed_keyword else kw.keyword, + 'volume': kw.volume, + 'difficulty': kw.difficulty, + }) + + return { + 'cluster': cluster, # Store original object + 'cluster_data': { + 'id': cluster.id, + 'name': cluster.name, + 'description': cluster.description or '', + 'keywords': keywords, + } + } + + def build_prompt(self, data: Dict, account=None) -> str: + """Build idea generation prompt""" + cluster_data = data.get('cluster_data', {}) + keywords = cluster_data.get('keywords', []) + keyword_list = [kw['keyword'] for kw in keywords] + + # Format clusters text + clusters_text = f"Cluster ID: {cluster_data.get('id', '')} | Name: {cluster_data.get('name', '')} | Description: {cluster_data.get('description', '')}" + + # Format cluster keywords + cluster_keywords_text = f"Cluster ID: {cluster_data.get('id', '')} | Name: {cluster_data.get('name', '')} | Keywords: {', '.join(keyword_list)}" + + # Get prompt from registry + prompt = PromptRegistry.get_prompt( + function_name='generate_ideas', + account=account, + context={ + 'CLUSTERS': clusters_text, + 'CLUSTER_KEYWORDS': cluster_keywords_text, + } + ) + + return prompt + + def parse_response(self, response: str, step_tracker=None) -> List[Dict]: + """Parse AI response into idea data""" + if not response or not response.strip(): + raise ValueError("Empty response from AI") + + ai_core = AICore(account=getattr(self, 'account', None)) + json_data = ai_core.extract_json(response) + + if not json_data or 'ideas' not in json_data: + raise ValueError("Invalid response format: missing 'ideas' array") + + return json_data.get('ideas', []) + + def save_output(self, parsed: List[Dict], original_data: Any, account=None, step_tracker=None) -> Dict: + """Save ideas to database""" + cluster = original_data.get('cluster') + if not cluster: + raise ValueError("Cluster not found in original data") + + ideas_created = 0 + + with transaction.atomic(): + for idea_data in parsed: + ContentIdeas.objects.create( + cluster=cluster, + title=idea_data.get('title', 'Untitled Idea'), + description=idea_data.get('description', ''), + structure=idea_data.get('structure', 'article'), + account=account, + status='new' + ) + ideas_created += 1 + + return { + 'ideas_created': ideas_created, + 'count': ideas_created + } + diff --git a/backend/igny8_core/ai/helpers/settings.py b/backend/igny8_core/ai/helpers/settings.py index 2b589ce7..49b7225d 100644 --- a/backend/igny8_core/ai/helpers/settings.py +++ b/backend/igny8_core/ai/helpers/settings.py @@ -34,6 +34,16 @@ MODEL_CONFIG = { "temperature": 0.7, "response_format": {"type": "json_object"}, }, + "auto_cluster_v2": { + "max_tokens": 3000, + "temperature": 0.7, + "response_format": {"type": "json_object"}, + }, + "generate_ideas_v2": { + "max_tokens": 4000, + "temperature": 0.7, + "response_format": {"type": "json_object"}, + }, } # Function name aliases (for backward compatibility) @@ -86,7 +96,26 @@ def get_model_config(function_name: str, account=None) -> Dict[str, Any]: logger = logging.getLogger(__name__) logger.warning(f"Could not load model from IntegrationSettings: {e}", exc_info=True) - # Merge with defaults + # For V2 functions: Don't use defaults - only return config if model is present + if function_name.endswith('_v2'): + # V2 functions require model from IntegrationSettings - no defaults + if not model_from_settings: + # Return config without model (will be validated in engine) + return { + "model": None, + "max_tokens": config.get('max_tokens', 4000), + "temperature": config.get('temperature', 0.7), + "response_format": config.get('response_format'), + } + # Model exists, return config with model + return { + "model": model_from_settings, + "max_tokens": config.get('max_tokens', 4000), + "temperature": config.get('temperature', 0.7), + "response_format": config.get('response_format'), + } + + # For non-V2 functions: Merge with defaults (backward compatibility) default_config = { "model": "gpt-4.1", "max_tokens": 4000, diff --git a/backend/igny8_core/ai/registry.py b/backend/igny8_core/ai/registry.py index a899b6fc..4c6a0414 100644 --- a/backend/igny8_core/ai/registry.py +++ b/backend/igny8_core/ai/registry.py @@ -89,8 +89,20 @@ def _load_generate_images(): from igny8_core.ai.functions.generate_images import GenerateImagesFunction return GenerateImagesFunction +def _load_auto_cluster_v2(): + """Lazy loader for auto_cluster_v2 function""" + from igny8_core.ai.functions.workflow_functions.auto_cluster_v2 import AutoClusterV2Function + return AutoClusterV2Function + +def _load_generate_ideas_v2(): + """Lazy loader for generate_ideas_v2 function""" + from igny8_core.ai.functions.workflow_functions.generate_ideas_v2 import GenerateIdeasV2Function + return GenerateIdeasV2Function + register_lazy_function('auto_cluster', _load_auto_cluster) register_lazy_function('generate_ideas', _load_generate_ideas) register_lazy_function('generate_content', _load_generate_content) register_lazy_function('generate_images', _load_generate_images) +register_lazy_function('auto_cluster_v2', _load_auto_cluster_v2) +register_lazy_function('generate_ideas_v2', _load_generate_ideas_v2) diff --git a/backend/igny8_core/modules/planner/views.py b/backend/igny8_core/modules/planner/views.py index 5fb39c94..e1d809d1 100644 --- a/backend/igny8_core/modules/planner/views.py +++ b/backend/igny8_core/modules/planner/views.py @@ -571,6 +571,118 @@ class KeywordViewSet(SiteSectorModelViewSet): 'error': f'Unexpected error: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + @action(detail=False, methods=['post'], url_path='auto_cluster_v2', url_name='auto_cluster_v2') + def auto_cluster_v2(self, request): + """Auto-cluster keywords V2 - New workflow function with max 50 keywords""" + import logging + from igny8_core.ai.tasks import run_ai_task + from kombu.exceptions import OperationalError as KombuOperationalError + + logger = logging.getLogger(__name__) + + try: + # Get account + account = getattr(request, 'account', None) + account_id = account.id if account else None + + # Check model exists - no default, only execute if model is present + if account: + from igny8_core.modules.system.models import IntegrationSettings + openai_settings = IntegrationSettings.objects.filter( + integration_type='openai', + account=account, + is_active=True + ).first() + if not openai_settings or not openai_settings.config or not openai_settings.config.get('model'): + return Response({ + 'success': False, + 'error': 'AI model not configured. Please configure OpenAI model in Integration settings.' + }, status=status.HTTP_400_BAD_REQUEST) + + # Prepare payload + payload = { + 'ids': request.data.get('ids', []), + 'sector_id': request.data.get('sector_id') + } + + logger.info(f"auto_cluster_v2 called with ids={payload['ids']}, sector_id={payload.get('sector_id')}") + + # Validate basic input + if not payload['ids']: + return Response({ + 'success': False, + 'error': 'No keywords selected' + }, status=status.HTTP_400_BAD_REQUEST) + + if len(payload['ids']) > 50: + return Response({ + 'success': False, + 'error': 'Maximum 50 keywords allowed for clustering' + }, status=status.HTTP_400_BAD_REQUEST) + + # Try to queue Celery task + try: + if hasattr(run_ai_task, 'delay'): + task = run_ai_task.delay( + function_name='auto_cluster_v2', + payload=payload, + account_id=account_id + ) + logger.info(f"Task queued: {task.id}") + return Response({ + 'success': True, + 'task_id': str(task.id), + 'message': 'Clustering started' + }, status=status.HTTP_200_OK) + else: + # Celery not available - execute synchronously + logger.warning("Celery not available, executing synchronously") + result = run_ai_task( + function_name='auto_cluster_v2', + payload=payload, + account_id=account_id + ) + if result.get('success'): + return Response({ + 'success': True, + **result + }, status=status.HTTP_200_OK) + else: + return Response({ + 'success': False, + 'error': result.get('error', 'Clustering failed') + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except (KombuOperationalError, ConnectionError) as e: + # Broker connection failed - fall back to synchronous execution + logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}") + result = run_ai_task( + function_name='auto_cluster_v2', + payload=payload, + account_id=account_id + ) + if result.get('success'): + return Response({ + 'success': True, + **result + }, status=status.HTTP_200_OK) + else: + return Response({ + 'success': False, + 'error': result.get('error', 'Clustering failed') + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + logger.error(f"Error in auto_cluster_v2: {str(e)}", exc_info=True) + return Response({ + 'success': False, + 'error': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + logger.error(f"Unexpected error in auto_cluster_v2: {str(e)}", exc_info=True) + return Response({ + 'success': False, + 'error': f'Unexpected error: {str(e)}' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class ClusterViewSet(SiteSectorModelViewSet): """ @@ -810,6 +922,117 @@ class ClusterViewSet(SiteSectorModelViewSet): 'success': False, 'error': f'Unexpected error: {str(e)}' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['post'], url_path='generate_ideas_v2', url_name='generate_ideas_v2') + def generate_ideas_v2(self, request): + """Generate ideas V2 - Single cluster only""" + import logging + from igny8_core.ai.tasks import run_ai_task + from kombu.exceptions import OperationalError as KombuOperationalError + + logger = logging.getLogger(__name__) + + try: + # Get account + account = getattr(request, 'account', None) + account_id = account.id if account else None + + # Check model exists - no default, only execute if model is present + if account: + from igny8_core.modules.system.models import IntegrationSettings + openai_settings = IntegrationSettings.objects.filter( + integration_type='openai', + account=account, + is_active=True + ).first() + if not openai_settings or not openai_settings.config or not openai_settings.config.get('model'): + return Response({ + 'success': False, + 'error': 'AI model not configured. Please configure OpenAI model in Integration settings.' + }, status=status.HTTP_400_BAD_REQUEST) + + # Prepare payload + payload = { + 'ids': request.data.get('ids', []), + } + + logger.info(f"generate_ideas_v2 called with ids={payload['ids']}") + + # Validate basic input - exactly one cluster + if not payload['ids']: + return Response({ + 'success': False, + 'error': 'No cluster selected' + }, status=status.HTTP_400_BAD_REQUEST) + + if len(payload['ids']) > 1: + return Response({ + 'success': False, + 'error': 'Only one cluster can be processed at a time' + }, status=status.HTTP_400_BAD_REQUEST) + + # Try to queue Celery task + try: + if hasattr(run_ai_task, 'delay'): + task = run_ai_task.delay( + function_name='generate_ideas_v2', + payload=payload, + account_id=account_id + ) + logger.info(f"Task queued: {task.id}") + return Response({ + 'success': True, + 'task_id': str(task.id), + 'message': 'Idea generation started' + }, status=status.HTTP_200_OK) + else: + # Celery not available - execute synchronously + logger.warning("Celery not available, executing synchronously") + result = run_ai_task( + function_name='generate_ideas_v2', + payload=payload, + account_id=account_id + ) + if result.get('success'): + return Response({ + 'success': True, + **result + }, status=status.HTTP_200_OK) + else: + return Response({ + 'success': False, + 'error': result.get('error', 'Idea generation failed') + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except (KombuOperationalError, ConnectionError) as e: + # Broker connection failed - fall back to synchronous execution + logger.warning(f"Celery broker unavailable, falling back to synchronous execution: {str(e)}") + result = run_ai_task( + function_name='generate_ideas_v2', + payload=payload, + account_id=account_id + ) + if result.get('success'): + return Response({ + 'success': True, + **result + }, status=status.HTTP_200_OK) + else: + return Response({ + 'success': False, + 'error': result.get('error', 'Idea generation failed') + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + logger.error(f"Error in generate_ideas_v2: {str(e)}", exc_info=True) + return Response({ + 'success': False, + 'error': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + logger.error(f"Unexpected error in generate_ideas_v2: {str(e)}", exc_info=True) + return Response({ + 'success': False, + 'error': f'Unexpected error: {str(e)}' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) def list(self, request, *args, **kwargs): """ diff --git a/frontend/src/components/common/AIProgressModal.tsx b/frontend/src/components/common/AIProgressModal.tsx index f2f95947..c5049df8 100644 --- a/frontend/src/components/common/AIProgressModal.tsx +++ b/frontend/src/components/common/AIProgressModal.tsx @@ -66,7 +66,7 @@ export default function AIProgressModal({ const modalInstanceId = modalInstanceIdRef.current || 'modal-01'; - // Build full function ID with modal instance + // Build full function ID with modal instance (only for debugging, not shown in UI) const fullFunctionId = functionId ? `${functionId}-${modalInstanceId}` : null; // Determine color based on status @@ -201,11 +201,10 @@ export default function AIProgressModal({ )} - {/* Function ID and Task ID (for debugging) */} - {(fullFunctionId || taskId) && ( + {/* Task ID (for debugging - Function ID not shown per requirements) */} + {taskId && (
- {fullFunctionId &&
Function ID: {fullFunctionId}
} - {taskId &&
Task ID: {taskId}
} +
Task ID: {taskId}
)} @@ -282,11 +281,10 @@ export default function AIProgressModal({ {config.errorMessage || message}

- {/* Function ID and Task ID (for debugging) */} - {(fullFunctionId || taskId) && ( + {/* Task ID (for debugging - Function ID not shown per requirements) */} + {taskId && (
- {fullFunctionId &&
Function ID: {fullFunctionId}
} - {taskId &&
Task ID: {taskId}
} +
Task ID: {taskId}
)} @@ -390,15 +388,10 @@ export default function AIProgressModal({ )} - {/* Function ID and Task ID (for debugging) */} - {(fullFunctionId || taskId) && ( + {/* Task ID (for debugging - Function ID not shown per requirements) */} + {taskId && (
- {fullFunctionId && ( -
Function ID: {fullFunctionId}
- )} - {taskId && ( -
Task ID: {taskId}
- )} +
Task ID: {taskId}
)} diff --git a/frontend/src/config/pages/table-actions.config.tsx b/frontend/src/config/pages/table-actions.config.tsx index 53998537..94de02ec 100644 --- a/frontend/src/config/pages/table-actions.config.tsx +++ b/frontend/src/config/pages/table-actions.config.tsx @@ -144,6 +144,12 @@ const tableActionsConfigs: Record = { icon: , variant: 'secondary', }, + { + key: 'keywords_clustering', + label: 'Keywords Clustering', + icon: , + variant: 'secondary', + }, ], }, '/planner/clusters': { @@ -160,6 +166,12 @@ const tableActionsConfigs: Record = { icon: , variant: 'primary', }, + { + key: 'generate_ideas_v2', + label: 'Generate Ideas V2', + icon: , + variant: 'primary', + }, ], bulkActions: [ { diff --git a/frontend/src/hooks/useProgressModal.ts b/frontend/src/hooks/useProgressModal.ts index 36bf9750..9148692c 100644 --- a/frontend/src/hooks/useProgressModal.ts +++ b/frontend/src/hooks/useProgressModal.ts @@ -232,8 +232,10 @@ export function useProgressModal(): UseProgressModalReturn { `/v1/system/settings/task_progress/${taskId}/` ); - // Helper function to start auto-increment progress (1% every 350ms until 80%) - // Only runs when no backend updates are coming (smooth fill-in animation) + // Helper function to start auto-increment progress + // 0-50%: 300ms per 1% + // 50-80%: 500ms per 1% + // If stuck at 80%: 1% per 500ms const startAutoIncrement = () => { // Clear any existing auto-increment interval if (autoIncrementIntervalRef.current) { @@ -241,11 +243,10 @@ export function useProgressModal(): UseProgressModalReturn { autoIncrementIntervalRef.current = null; } - // Only start if we're below 80% and status is processing + // Only start if we're below 100% and status is processing const current = displayedPercentageRef.current; - if (current < 80) { - // Use a slightly longer interval to avoid conflicts with backend updates - autoIncrementIntervalRef.current = setInterval(() => { + if (current < 100) { + const doIncrement = () => { setProgress(prev => { // Check current status - stop if not processing if (prev.status !== 'processing') { @@ -257,17 +258,30 @@ export function useProgressModal(): UseProgressModalReturn { } const currentPercent = displayedPercentageRef.current; - // Only increment if still below 80% - if (currentPercent < 80) { - const newPercentage = Math.min(currentPercent + 1, 80); + let newPercentage = currentPercent; + let nextInterval = 300; // Default 300ms + + if (currentPercent < 50) { + // 0-50%: 300ms per 1% + newPercentage = Math.min(currentPercent + 1, 50); + nextInterval = 300; + } else if (currentPercent < 80) { + // 50-80%: 500ms per 1% + newPercentage = Math.min(currentPercent + 1, 80); + nextInterval = 500; + } else if (currentPercent < 100) { + // Stuck at 80%+: 1% per 500ms + newPercentage = Math.min(currentPercent + 1, 99); + nextInterval = 500; + } + + if (newPercentage > currentPercent && newPercentage < 100) { displayedPercentageRef.current = newPercentage; - // Stop if we've reached 80% - if (newPercentage >= 80) { - if (autoIncrementIntervalRef.current) { - clearInterval(autoIncrementIntervalRef.current); - autoIncrementIntervalRef.current = null; - } + // Restart interval with new speed if needed + if (autoIncrementIntervalRef.current) { + clearInterval(autoIncrementIntervalRef.current); + autoIncrementIntervalRef.current = setInterval(doIncrement, nextInterval); } return { @@ -275,7 +289,7 @@ export function useProgressModal(): UseProgressModalReturn { percentage: newPercentage, }; } else { - // Stop if we've reached 80% + // Stop if we've reached 100% or can't increment if (autoIncrementIntervalRef.current) { clearInterval(autoIncrementIntervalRef.current); autoIncrementIntervalRef.current = null; @@ -283,7 +297,11 @@ export function useProgressModal(): UseProgressModalReturn { return prev; } }); - }, 350); // Slightly longer interval to reduce conflicts + }; + + // Start with appropriate interval based on current percentage + const initialInterval = current < 50 ? 300 : 500; + autoIncrementIntervalRef.current = setInterval(doIncrement, initialInterval); } }; @@ -387,19 +405,20 @@ export function useProgressModal(): UseProgressModalReturn { const safeTargetPercentage = Math.max(targetPercentage, currentDisplayedPercentage); // Smooth progress animation: increment gradually until reaching target - // Use smaller increments and faster updates for smoother animation + // Speed: 300ms per 1% until 50%, then 500ms per 1% if (safeTargetPercentage > currentDisplayedPercentage) { // Start smooth animation let animatedPercentage = currentDisplayedPercentage; const animateProgress = () => { if (animatedPercentage < safeTargetPercentage) { - // Calculate increment based on distance for smooth animation - const diff = safeTargetPercentage - animatedPercentage; - // Use smaller increments for smoother feel - // If close (< 5%), increment by 1, otherwise by 2 - const increment = diff <= 5 ? 1 : Math.min(2, Math.ceil(diff / 10)); + // Always increment by 1% + const increment = 1; animatedPercentage = Math.min(animatedPercentage + increment, safeTargetPercentage); displayedPercentageRef.current = animatedPercentage; + + // Determine speed based on current percentage + const speed = animatedPercentage < 50 ? 300 : 500; // 300ms until 50%, then 500ms + setProgress({ percentage: animatedPercentage, message: friendlyMessage, @@ -414,13 +433,17 @@ export function useProgressModal(): UseProgressModalReturn { }); if (animatedPercentage < safeTargetPercentage) { - // Smooth updates: 150ms for better UX - stepTransitionTimeoutRef.current = setTimeout(animateProgress, 150); + // Use appropriate speed based on current percentage + const nextSpeed = animatedPercentage < 50 ? 300 : 500; + stepTransitionTimeoutRef.current = setTimeout(animateProgress, nextSpeed); } else { stepTransitionTimeoutRef.current = null; // After reaching target, start auto-increment if below 80% and no backend update pending if (safeTargetPercentage < 80) { startAutoIncrement(); + } else if (safeTargetPercentage >= 80 && safeTargetPercentage < 100) { + // If at 80%+, start slow auto-increment (1% per 500ms) + startAutoIncrement(); } } } @@ -435,7 +458,8 @@ export function useProgressModal(): UseProgressModalReturn { } else { // Same step or first step - start animation immediately currentStepRef.current = currentStep; - animateProgress(); + const initialSpeed = currentDisplayedPercentage < 50 ? 300 : 500; + stepTransitionTimeoutRef.current = setTimeout(animateProgress, initialSpeed); } } else { // Target is same or less than current - just update message and details @@ -455,6 +479,9 @@ export function useProgressModal(): UseProgressModalReturn { // Start auto-increment if below 80% and no backend update if (currentDisplayedPercentage < 80 && safeTargetPercentage === currentDisplayedPercentage) { startAutoIncrement(); + } else if (currentDisplayedPercentage >= 80 && currentDisplayedPercentage < 100) { + // If at 80%+, start slow auto-increment (1% per 500ms) + startAutoIncrement(); } } @@ -527,26 +554,61 @@ export function useProgressModal(): UseProgressModalReturn { } else if (response.state === 'SUCCESS') { const meta = response.meta || {}; - // Clear any existing transition timeout + // Clear any existing transition timeout and auto-increment if (stepTransitionTimeoutRef.current) { clearTimeout(stepTransitionTimeoutRef.current); stepTransitionTimeoutRef.current = null; } + if (autoIncrementIntervalRef.current) { + clearInterval(autoIncrementIntervalRef.current); + autoIncrementIntervalRef.current = null; + } // Get completion message with extracted values const completionMessage = meta.message || ''; const allSteps = [...(meta.request_steps || []), ...(meta.response_steps || [])]; const stepInfo = getStepInfo('DONE', completionMessage, allSteps); - // Update to 100% with user-friendly completion message - currentStepRef.current = 'DONE'; - displayedPercentageRef.current = 100; - setProgress({ - percentage: 100, - message: stepInfo.friendlyMessage, - status: 'completed', - details: meta.details, - }); + // Smooth completion animation: 5% per 500ms until 100% + const currentPercent = displayedPercentageRef.current; + if (currentPercent < 100) { + const animateToCompletion = () => { + const current = displayedPercentageRef.current; + if (current < 100) { + const increment = Math.min(5, 100 - current); // 5% per step, or remaining if less + const newPercentage = current + increment; + displayedPercentageRef.current = newPercentage; + + setProgress({ + percentage: newPercentage, + message: stepInfo.friendlyMessage, + status: 'completed', + details: meta.details, + }); + + if (newPercentage < 100) { + stepTransitionTimeoutRef.current = setTimeout(animateToCompletion, 500); + } else { + currentStepRef.current = 'DONE'; + } + } else { + currentStepRef.current = 'DONE'; + } + }; + + currentStepRef.current = 'DONE'; + animateToCompletion(); + } else { + // Already at 100%, just update message and status + currentStepRef.current = 'DONE'; + displayedPercentageRef.current = 100; + setProgress({ + percentage: 100, + message: stepInfo.friendlyMessage, + status: 'completed', + details: meta.details, + }); + } // Update final step logs if (meta.request_steps || meta.response_steps) { diff --git a/frontend/src/pages/Planner/Clusters.tsx b/frontend/src/pages/Planner/Clusters.tsx index b402d315..3290c507 100644 --- a/frontend/src/pages/Planner/Clusters.tsx +++ b/frontend/src/pages/Planner/Clusters.tsx @@ -13,6 +13,7 @@ import { bulkDeleteClusters, bulkUpdateClustersStatus, autoGenerateIdeas, + generateIdeasV2, Cluster, ClusterFilters, ClusterCreateData, @@ -218,6 +219,22 @@ export default function Clusters() { } catch (error: any) { toast.error(`Failed to generate ideas: ${error.message}`); } + } else if (action === 'generate_ideas_v2') { + try { + const result = await generateIdeasV2([row.id]); + + if (result.success && result.task_id) { + // Async task - show progress modal + progressModal.openModal(result.task_id, 'Generate Ideas', 'ai_generate_ideas_v2'); + } else if (result.success) { + toast.success(result.message || 'Ideas generated successfully'); + await loadClusters(); + } else { + toast.error(result.error || 'Failed to generate ideas'); + } + } catch (error: any) { + toast.error(`Failed to generate ideas: ${error.message}`); + } } }, [toast, progressModal, loadClusters]); diff --git a/frontend/src/pages/Planner/Keywords.tsx b/frontend/src/pages/Planner/Keywords.tsx index 43ab7bbe..fbd891a7 100644 --- a/frontend/src/pages/Planner/Keywords.tsx +++ b/frontend/src/pages/Planner/Keywords.tsx @@ -20,6 +20,7 @@ import { Cluster, API_BASE_URL, autoClusterKeywords, + autoClusterKeywordsV2, fetchSeedKeywords, SeedKeyword, } from '../../services/api'; @@ -448,6 +449,35 @@ export default function Keywords() { }]); toast.error(errorMsg); } + } else if (action === 'keywords_clustering') { + if (ids.length === 0) { + toast.error('Please select at least one keyword'); + return; + } + if (ids.length > 50) { + toast.error('Maximum 50 keywords allowed for clustering'); + return; + } + + const numIds = ids.map(id => parseInt(id)); + const sectorId = activeSector?.id; + + try { + const result = await autoClusterKeywordsV2(numIds, sectorId); + + if (result.success && result.task_id) { + // Async task - open progress modal + hasReloadedRef.current = false; + progressModal.openModal(result.task_id, 'Keywords Clustering', 'ai_auto_cluster_v2'); + } else if (result.success) { + toast.success(result.message || 'Clustering completed'); + await loadKeywords(); + } else { + toast.error(result.error || 'Clustering failed'); + } + } catch (error: any) { + toast.error(`Clustering failed: ${error.message}`); + } } else { toast.info(`Bulk action "${action}" for ${ids.length} items`); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 6f04a92f..08249e54 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -807,6 +807,140 @@ export async function autoGenerateIdeas(clusterIds: number[]): Promise<{ success } } +export async function autoClusterKeywordsV2(keywordIds: number[], sectorId?: number): Promise<{ success: boolean; task_id?: string; clusters_created?: number; keywords_updated?: number; message?: string; error?: string }> { + const startTime = Date.now(); + const addLog = useAIRequestLogsStore.getState().addLog; + + const endpoint = `/v1/planner/keywords/auto_cluster_v2/`; + const requestBody = { ids: keywordIds, sector_id: sectorId }; + + const pendingLogId = addLog({ + function: 'autoClusterKeywordsV2', + endpoint, + request: { + method: 'POST', + body: requestBody, + }, + status: 'pending', + }); + + try { + const response = await fetchAPI(endpoint, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + const duration = Date.now() - startTime; + const updateLog = useAIRequestLogsStore.getState().updateLog; + + if (pendingLogId && response) { + updateLog(pendingLogId, { + response: { + status: 200, + data: response, + }, + status: response.success === false ? 'error' : 'success', + duration, + }); + } + + if (response && response.success === false) { + return response; + } + + return response; + } catch (error: any) { + const duration = Date.now() - startTime; + const updateLog = useAIRequestLogsStore.getState().updateLog; + + let errorMessage = error.message || 'Unknown error'; + + if (pendingLogId) { + updateLog(pendingLogId, { + response: { + status: 500, + error: errorMessage, + }, + status: 'error', + duration, + }); + } + + return { + success: false, + error: errorMessage, + }; + } +} + +export async function generateIdeasV2(clusterIds: number[]): Promise<{ success: boolean; task_id?: string; ideas_created?: number; message?: string; error?: string }> { + const startTime = Date.now(); + const { useAIRequestLogsStore } = await import('../store/aiRequestLogsStore').catch(() => ({ useAIRequestLogsStore: null })); + const addLog = useAIRequestLogsStore?.getState().addLog; + + const endpoint = `/v1/planner/clusters/generate_ideas_v2/`; + const requestBody = { ids: clusterIds }; + + addLog?.({ + function: 'generateIdeasV2', + endpoint, + request: { + method: 'POST', + body: requestBody, + }, + status: 'pending', + }); + + try { + const response = await fetchAPI(endpoint, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + + const duration = Date.now() - startTime; + addLog?.({ + function: 'generateIdeasV2', + endpoint, + request: { + method: 'POST', + body: requestBody, + }, + response: { + status: 200, + data: response, + }, + status: 'success', + duration, + }); + + return response; + } catch (error: any) { + const duration = Date.now() - startTime; + + let errorMessage = error.message || 'Unknown error'; + + addLog?.({ + function: 'generateIdeasV2', + endpoint, + request: { + method: 'POST', + body: requestBody, + }, + response: { + status: 500, + error: errorMessage, + }, + status: 'error', + duration, + }); + + return { + success: false, + error: errorMessage, + }; + } +} + export async function generateSingleIdea(ideaId: string | number, clusterId: number): Promise<{ success: boolean; task_id?: string; idea_created?: number; message?: string; error?: string }> { const startTime = Date.now(); const { useAIRequestLogsStore } = await import('../store/aiRequestLogsStore').catch(() => ({ useAIRequestLogsStore: null }));