This commit is contained in:
Desktop
2025-11-10 22:05:35 +05:00
parent c21ce01cd2
commit 46f5bb4d62
10 changed files with 2193 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""
Workflow Functions
New AI functions using the unified template pattern.
"""

View File

@@ -0,0 +1,755 @@
"""
AI Core - Centralized execution and logging layer for all AI requests
Handles API calls, model selection, response parsing, and console logging
"""
import logging
import json
import re
import requests
import time
from typing import Dict, Any, Optional, List
from django.conf import settings
from igny8_core.ai.constants import (
DEFAULT_AI_MODEL,
JSON_MODE_MODELS,
MODEL_RATES,
IMAGE_MODEL_RATES,
VALID_OPENAI_IMAGE_MODELS,
VALID_SIZES_BY_MODEL,
DEBUG_MODE,
)
from .tracker import ConsoleStepTracker
logger = logging.getLogger(__name__)
class AICore:
"""
Centralized AI operations handler with console logging.
All AI requests go through run_ai_request() for consistent execution and logging.
"""
def __init__(self, account=None):
"""
Initialize AICore with account context.
Args:
account: Optional account object for API key/model loading
"""
self.account = account
self._openai_api_key = None
self._runware_api_key = None
self._default_model = None
self._load_account_settings()
def _load_account_settings(self):
"""Load API keys and model from IntegrationSettings or Django settings"""
if self.account:
try:
from igny8_core.modules.system.models import IntegrationSettings
# Load OpenAI settings
openai_settings = IntegrationSettings.objects.filter(
integration_type='openai',
account=self.account,
is_active=True
).first()
if openai_settings and openai_settings.config:
self._openai_api_key = openai_settings.config.get('apiKey')
model = openai_settings.config.get('model')
if model:
if model in MODEL_RATES:
self._default_model = model
logger.info(f"Loaded model '{model}' from IntegrationSettings for account {self.account.id}")
else:
error_msg = f"Model '{model}' from IntegrationSettings is not in supported models list. Supported models: {list(MODEL_RATES.keys())}"
logger.error(f"[AICore] {error_msg}")
logger.error(f"[AICore] Account {self.account.id} has invalid model configuration. Please update Integration Settings.")
# Don't set _default_model, will fall back to Django settings
else:
logger.warning(f"No model configured in IntegrationSettings for account {self.account.id}, will use fallback")
# Load Runware settings
runware_settings = IntegrationSettings.objects.filter(
integration_type='runware',
account=self.account,
is_active=True
).first()
if runware_settings and runware_settings.config:
self._runware_api_key = runware_settings.config.get('apiKey')
except Exception as e:
logger.warning(f"Could not load account settings: {e}", exc_info=True)
# Fallback to Django settings
if not self._openai_api_key:
self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None)
if not self._runware_api_key:
self._runware_api_key = getattr(settings, 'RUNWARE_API_KEY', None)
if not self._default_model:
self._default_model = getattr(settings, 'DEFAULT_AI_MODEL', DEFAULT_AI_MODEL)
def get_api_key(self, integration_type: str = 'openai') -> Optional[str]:
"""Get API key for integration type"""
if integration_type == 'openai':
return self._openai_api_key
elif integration_type == 'runware':
return self._runware_api_key
return None
def get_model(self, integration_type: str = 'openai') -> str:
"""Get model for integration type"""
if integration_type == 'openai':
return self._default_model
return DEFAULT_AI_MODEL
def run_ai_request(
self,
prompt: str,
model: Optional[str] = None,
max_tokens: int = 4000,
temperature: float = 0.7,
response_format: Optional[Dict] = None,
api_key: Optional[str] = None,
function_name: str = 'ai_request',
function_id: Optional[str] = None,
tracker: Optional[ConsoleStepTracker] = None
) -> Dict[str, Any]:
"""
Centralized AI request handler with console logging.
All AI text generation requests go through this method.
Args:
prompt: Prompt text
model: Model name (defaults to account's default)
max_tokens: Maximum tokens
temperature: Temperature (0-1)
response_format: Optional response format dict (for JSON mode)
api_key: Optional API key override
function_name: Function name for logging (e.g., 'cluster_keywords')
tracker: Optional ConsoleStepTracker instance for logging
Returns:
Dict with 'content', 'input_tokens', 'output_tokens', 'total_tokens',
'model', 'cost', 'error', 'api_id'
"""
# Use provided tracker or create a new one
if tracker is None:
tracker = ConsoleStepTracker(function_name)
tracker.ai_call("Preparing request...")
# Step 1: Validate API key
api_key = api_key or self._openai_api_key
if not api_key:
error_msg = 'OpenAI API key not configured'
tracker.error('ConfigurationError', error_msg)
return {
'content': None,
'error': error_msg,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': model or self._default_model,
'cost': 0.0,
'api_id': None,
}
# Step 2: Determine model
active_model = model or self._default_model
# Debug logging: Show model from settings vs model used
model_from_settings = self._default_model
model_used = active_model
logger.info(f"[AICore] Model Configuration Debug:")
logger.info(f" - Model from IntegrationSettings: {model_from_settings}")
logger.info(f" - Model parameter passed: {model}")
logger.info(f" - Model actually used in request: {model_used}")
tracker.ai_call(f"Model Debug - Settings: {model_from_settings}, Parameter: {model}, Using: {model_used}")
# Validate model is available and supported
if not active_model:
error_msg = 'No AI model configured. Please configure a model in Integration Settings or Django settings.'
logger.error(f"[AICore] {error_msg}")
tracker.error('ConfigurationError', error_msg)
return {
'content': None,
'error': error_msg,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': None,
'cost': 0.0,
'api_id': None,
}
if active_model not in MODEL_RATES:
error_msg = f"Model '{active_model}' is not supported. Supported models: {list(MODEL_RATES.keys())}"
logger.error(f"[AICore] {error_msg}")
tracker.error('ConfigurationError', error_msg)
return {
'content': None,
'error': error_msg,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': active_model,
'cost': 0.0,
'api_id': None,
}
tracker.ai_call(f"Using model: {active_model}")
# Step 3: Auto-enable JSON mode for supported models
if response_format is None and active_model in JSON_MODE_MODELS:
response_format = {'type': 'json_object'}
tracker.ai_call(f"Auto-enabled JSON mode for {active_model}")
elif response_format:
tracker.ai_call(f"Using custom response format: {response_format}")
else:
tracker.ai_call("Using text response format")
# Step 4: Validate prompt length and add function_id
prompt_length = len(prompt)
tracker.ai_call(f"Prompt length: {prompt_length} characters")
# Add function_id to prompt if provided (for tracking)
final_prompt = prompt
if function_id:
function_id_prefix = f'function_id: "{function_id}"\n\n'
final_prompt = function_id_prefix + prompt
tracker.ai_call(f"Added function_id to prompt: {function_id}")
# Step 5: Build request payload
url = 'https://api.openai.com/v1/chat/completions'
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
body_data = {
'model': active_model,
'messages': [{'role': 'user', 'content': final_prompt}],
'temperature': temperature,
}
if max_tokens:
body_data['max_tokens'] = max_tokens
if response_format:
body_data['response_format'] = response_format
tracker.ai_call(f"Request payload prepared (model={active_model}, max_tokens={max_tokens}, temp={temperature})")
# Step 6: Send request
tracker.ai_call("Sending request to OpenAI API...")
request_start = time.time()
try:
response = requests.post(url, headers=headers, json=body_data, timeout=60)
request_duration = time.time() - request_start
tracker.ai_call(f"Received response in {request_duration:.2f}s (status={response.status_code})")
# Step 7: Validate HTTP response
if response.status_code != 200:
error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {}
error_message = f"HTTP {response.status_code} error"
if isinstance(error_data, dict) and 'error' in error_data:
if isinstance(error_data['error'], dict) and 'message' in error_data['error']:
error_message += f": {error_data['error']['message']}"
# Check for rate limit
if response.status_code == 429:
retry_after = response.headers.get('retry-after', '60')
tracker.rate_limit(retry_after)
error_message += f" (Rate limit - retry after {retry_after}s)"
else:
tracker.error('HTTPError', error_message)
logger.error(f"OpenAI API HTTP error {response.status_code}: {error_message}")
return {
'content': None,
'error': error_message,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': active_model,
'cost': 0.0,
'api_id': None,
}
# Step 8: Parse response JSON
try:
data = response.json()
except json.JSONDecodeError as e:
error_msg = f'Failed to parse JSON response: {str(e)}'
tracker.malformed_json(str(e))
logger.error(error_msg)
return {
'content': None,
'error': error_msg,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': active_model,
'cost': 0.0,
'api_id': None,
}
api_id = data.get('id')
# Step 9: Extract content
if 'choices' in data and len(data['choices']) > 0:
content = data['choices'][0]['message']['content']
usage = data.get('usage', {})
input_tokens = usage.get('prompt_tokens', 0)
output_tokens = usage.get('completion_tokens', 0)
total_tokens = usage.get('total_tokens', 0)
tracker.parse(f"Received {total_tokens} tokens (input: {input_tokens}, output: {output_tokens})")
tracker.parse(f"Content length: {len(content)} characters")
# Step 10: Calculate cost
rates = MODEL_RATES.get(active_model, {'input': 2.00, 'output': 8.00})
cost = (input_tokens * rates['input'] + output_tokens * rates['output']) / 1_000_000
tracker.parse(f"Cost calculated: ${cost:.6f}")
tracker.done("Request completed successfully")
return {
'content': content,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'total_tokens': total_tokens,
'model': active_model,
'cost': cost,
'error': None,
'api_id': api_id,
'duration': request_duration, # Add duration tracking
}
else:
error_msg = 'No content in OpenAI response'
tracker.error('EmptyResponse', error_msg)
logger.error(error_msg)
return {
'content': None,
'error': error_msg,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': active_model,
'cost': 0.0,
'api_id': api_id,
}
except requests.exceptions.Timeout:
error_msg = 'Request timeout (60s exceeded)'
tracker.timeout(60)
logger.error(error_msg)
return {
'content': None,
'error': error_msg,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': active_model,
'cost': 0.0,
'api_id': None,
}
except requests.exceptions.RequestException as e:
error_msg = f'Request exception: {str(e)}'
tracker.error('RequestException', error_msg, e)
logger.error(f"OpenAI API error: {error_msg}", exc_info=True)
return {
'content': None,
'error': error_msg,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': active_model,
'cost': 0.0,
'api_id': None,
}
except Exception as e:
error_msg = f'Unexpected error: {str(e)}'
logger.error(f"[AI][{function_name}][Error] {error_msg}", exc_info=True)
if tracker:
tracker.error('UnexpectedError', error_msg, e)
return {
'content': None,
'error': error_msg,
'input_tokens': 0,
'output_tokens': 0,
'total_tokens': 0,
'model': active_model,
'cost': 0.0,
'api_id': None,
}
def extract_json(self, response_text: str) -> Optional[Dict]:
"""
Extract JSON from response text.
Handles markdown code blocks, multiline JSON, etc.
Args:
response_text: Raw response text from AI
Returns:
Parsed JSON dict or None
"""
if not response_text or not response_text.strip():
return None
# Try direct JSON parse first
try:
return json.loads(response_text.strip())
except json.JSONDecodeError:
pass
# Try to extract JSON from markdown code blocks
json_block_pattern = r'```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```'
matches = re.findall(json_block_pattern, response_text, re.DOTALL)
if matches:
try:
return json.loads(matches[0])
except json.JSONDecodeError:
pass
# Try to find JSON object/array in text
json_pattern = r'(\{.*\}|\[.*\])'
matches = re.findall(json_pattern, response_text, re.DOTALL)
for match in matches:
try:
return json.loads(match)
except json.JSONDecodeError:
continue
return None
def generate_image(
self,
prompt: str,
provider: str = 'openai',
model: Optional[str] = None,
size: str = '1024x1024',
n: int = 1,
api_key: Optional[str] = None,
negative_prompt: Optional[str] = None,
function_name: str = 'generate_image'
) -> Dict[str, Any]:
"""
Generate image using AI with console logging.
Args:
prompt: Image prompt
provider: 'openai' or 'runware'
model: Model name
size: Image size
n: Number of images
api_key: Optional API key override
negative_prompt: Optional negative prompt
function_name: Function name for logging
Returns:
Dict with 'url', 'revised_prompt', 'cost', 'error', etc.
"""
print(f"[AI][{function_name}] Step 1: Preparing image generation request...")
if provider == 'openai':
return self._generate_image_openai(prompt, model, size, n, api_key, negative_prompt, function_name)
elif provider == 'runware':
return self._generate_image_runware(prompt, model, size, n, api_key, negative_prompt, function_name)
else:
error_msg = f'Unknown provider: {provider}'
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'revised_prompt': None,
'provider': provider,
'cost': 0.0,
'error': error_msg,
}
def _generate_image_openai(
self,
prompt: str,
model: Optional[str],
size: str,
n: int,
api_key: Optional[str],
negative_prompt: Optional[str],
function_name: str
) -> Dict[str, Any]:
"""Generate image using OpenAI DALL-E"""
print(f"[AI][{function_name}] Provider: OpenAI")
api_key = api_key or self._openai_api_key
if not api_key:
error_msg = 'OpenAI API key not configured'
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'revised_prompt': None,
'provider': 'openai',
'cost': 0.0,
'error': error_msg,
}
model = model or 'dall-e-3'
print(f"[AI][{function_name}] Step 2: Using model: {model}, size: {size}")
# Validate model
if model not in VALID_OPENAI_IMAGE_MODELS:
error_msg = f"Model '{model}' is not valid for OpenAI image generation. Only {', '.join(VALID_OPENAI_IMAGE_MODELS)} are supported."
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'revised_prompt': None,
'provider': 'openai',
'cost': 0.0,
'error': error_msg,
}
# Validate size
valid_sizes = VALID_SIZES_BY_MODEL.get(model, [])
if size not in valid_sizes:
error_msg = f"Image size '{size}' is not valid for model '{model}'. Valid sizes: {', '.join(valid_sizes)}"
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'revised_prompt': None,
'provider': 'openai',
'cost': 0.0,
'error': error_msg,
}
url = 'https://api.openai.com/v1/images/generations'
print(f"[AI][{function_name}] Step 3: Sending request to OpenAI Images API...")
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json',
}
data = {
'model': model,
'prompt': prompt,
'n': n,
'size': size
}
if negative_prompt:
# Note: OpenAI DALL-E doesn't support negative_prompt in API, but we log it
print(f"[AI][{function_name}] Note: Negative prompt provided but OpenAI DALL-E doesn't support it")
request_start = time.time()
try:
response = requests.post(url, headers=headers, json=data, timeout=150)
request_duration = time.time() - request_start
print(f"[AI][{function_name}] Step 4: Received response in {request_duration:.2f}s (status={response.status_code})")
if response.status_code != 200:
error_data = response.json() if response.headers.get('content-type', '').startswith('application/json') else {}
error_message = f"HTTP {response.status_code} error"
if isinstance(error_data, dict) and 'error' in error_data:
if isinstance(error_data['error'], dict) and 'message' in error_data['error']:
error_message += f": {error_data['error']['message']}"
print(f"[AI][{function_name}][Error] {error_message}")
return {
'url': None,
'revised_prompt': None,
'provider': 'openai',
'cost': 0.0,
'error': error_message,
}
body = response.json()
if 'data' in body and len(body['data']) > 0:
image_data = body['data'][0]
image_url = image_data.get('url')
revised_prompt = image_data.get('revised_prompt')
cost = IMAGE_MODEL_RATES.get(model, 0.040) * n
print(f"[AI][{function_name}] Step 5: Image generated successfully")
print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}")
print(f"[AI][{function_name}][Success] Image generation completed")
return {
'url': image_url,
'revised_prompt': revised_prompt,
'provider': 'openai',
'cost': cost,
'error': None,
}
else:
error_msg = 'No image data in response'
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'revised_prompt': None,
'provider': 'openai',
'cost': 0.0,
'error': error_msg,
}
except requests.exceptions.Timeout:
error_msg = 'Request timeout (150s exceeded)'
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'revised_prompt': None,
'provider': 'openai',
'cost': 0.0,
'error': error_msg,
}
except Exception as e:
error_msg = f'Unexpected error: {str(e)}'
print(f"[AI][{function_name}][Error] {error_msg}")
logger.error(error_msg, exc_info=True)
return {
'url': None,
'revised_prompt': None,
'provider': 'openai',
'cost': 0.0,
'error': error_msg,
}
def _generate_image_runware(
self,
prompt: str,
model: Optional[str],
size: str,
n: int,
api_key: Optional[str],
negative_prompt: Optional[str],
function_name: str
) -> Dict[str, Any]:
"""Generate image using Runware"""
print(f"[AI][{function_name}] Provider: Runware")
api_key = api_key or self._runware_api_key
if not api_key:
error_msg = 'Runware API key not configured'
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'provider': 'runware',
'cost': 0.0,
'error': error_msg,
}
runware_model = model or 'runware:97@1'
print(f"[AI][{function_name}] Step 2: Using model: {runware_model}, size: {size}")
# Parse size
try:
width, height = map(int, size.split('x'))
except ValueError:
error_msg = f"Invalid size format: {size}. Expected format: WIDTHxHEIGHT"
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'provider': 'runware',
'cost': 0.0,
'error': error_msg,
}
url = 'https://api.runware.ai/v1'
print(f"[AI][{function_name}] Step 3: Sending request to Runware API...")
# Runware uses array payload
payload = [{
'taskType': 'imageInference',
'model': runware_model,
'prompt': prompt,
'width': width,
'height': height,
'apiKey': api_key
}]
if negative_prompt:
payload[0]['negativePrompt'] = negative_prompt
request_start = time.time()
try:
response = requests.post(url, json=payload, timeout=150)
request_duration = time.time() - request_start
print(f"[AI][{function_name}] Step 4: Received response in {request_duration:.2f}s (status={response.status_code})")
if response.status_code != 200:
error_msg = f"HTTP {response.status_code} error"
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'provider': 'runware',
'cost': 0.0,
'error': error_msg,
}
body = response.json()
# Runware returns array with image data
if isinstance(body, list) and len(body) > 0:
image_data = body[0]
image_url = image_data.get('imageURL') or image_data.get('url')
cost = 0.036 * n # Runware pricing
print(f"[AI][{function_name}] Step 5: Image generated successfully")
print(f"[AI][{function_name}] Step 6: Cost: ${cost:.4f}")
print(f"[AI][{function_name}][Success] Image generation completed")
return {
'url': image_url,
'provider': 'runware',
'cost': cost,
'error': None,
}
else:
error_msg = 'No image data in Runware response'
print(f"[AI][{function_name}][Error] {error_msg}")
return {
'url': None,
'provider': 'runware',
'cost': 0.0,
'error': error_msg,
}
except Exception as e:
error_msg = f'Unexpected error: {str(e)}'
print(f"[AI][{function_name}][Error] {error_msg}")
logger.error(error_msg, exc_info=True)
return {
'url': None,
'provider': 'runware',
'cost': 0.0,
'error': error_msg,
}
def calculate_cost(self, model: str, input_tokens: int, output_tokens: int, model_type: str = 'text') -> float:
"""Calculate cost for API call"""
if model_type == 'text':
rates = MODEL_RATES.get(model, {'input': 2.00, 'output': 8.00})
input_cost = (input_tokens / 1_000_000) * rates['input']
output_cost = (output_tokens / 1_000_000) * rates['output']
return input_cost + output_cost
elif model_type == 'image':
rate = IMAGE_MODEL_RATES.get(model, 0.040)
return rate * 1
return 0.0
# Legacy method names for backward compatibility
def call_openai(self, prompt: str, model: Optional[str] = None, max_tokens: int = 4000,
temperature: float = 0.7, response_format: Optional[Dict] = None,
api_key: Optional[str] = None) -> Dict[str, Any]:
"""Legacy method - redirects to run_ai_request()"""
return self.run_ai_request(
prompt=prompt,
model=model,
max_tokens=max_tokens,
temperature=temperature,
response_format=response_format,
api_key=api_key,
function_name='call_openai'
)

View File

@@ -0,0 +1,94 @@
"""
Base class for all AI functions
"""
from abc import ABC, abstractmethod
from typing import Dict, List, Any, Optional
class BaseAIFunction(ABC):
"""
Base class for all AI functions.
Each function only implements its specific logic.
"""
@abstractmethod
def get_name(self) -> str:
"""Return function name (e.g., 'auto_cluster')"""
pass
def get_metadata(self) -> Dict:
"""Return function metadata (display name, description, phases)"""
return {
'display_name': self.get_name().replace('_', ' ').title(),
'description': f'{self.get_name()} AI function',
'phases': {
'INIT': 'Initializing...',
'PREP': 'Preparing data...',
'AI_CALL': 'Processing with AI...',
'PARSE': 'Parsing response...',
'SAVE': 'Saving results...',
'DONE': 'Complete!'
}
}
def validate(self, payload: dict, account=None) -> Dict[str, Any]:
"""
Validate input payload.
Default: checks for 'ids' array.
Override for custom validation.
"""
ids = payload.get('ids', [])
if not ids:
return {'valid': False, 'error': 'No IDs provided'}
# Removed max_items limit check - no limits enforced
return {'valid': True}
def get_max_items(self) -> Optional[int]:
"""Override to set max items limit"""
return None
@abstractmethod
def prepare(self, payload: dict, account=None) -> Any:
"""
Load and prepare data for AI processing.
Returns: prepared data structure
"""
pass
@abstractmethod
def build_prompt(self, data: Any, account=None) -> str:
"""
Build AI prompt from prepared data.
Returns: prompt string
"""
pass
def get_model(self, account=None) -> Optional[str]:
"""Override to specify model (defaults to account's default model)"""
return None # Uses account's default from AIProcessor
@abstractmethod
def parse_response(self, response: str, step_tracker=None) -> Any:
"""
Parse AI response into structured data.
Returns: parsed data structure
"""
pass
@abstractmethod
def save_output(
self,
parsed: Any,
original_data: Any,
account=None,
progress_tracker=None,
step_tracker=None
) -> Dict:
"""
Save parsed results to database.
Returns: dict with 'count', 'items_created', etc.
"""
pass

View File

@@ -0,0 +1,52 @@
"""
AI Framework Models
"""
from django.db import models
from igny8_core.auth.models import AccountBaseModel
class AITaskLog(AccountBaseModel):
"""
Unified logging table for all AI tasks.
Stores request/response steps, costs, tokens, and results.
"""
task_id = models.CharField(max_length=255, db_index=True, null=True, blank=True)
function_name = models.CharField(max_length=100, db_index=True)
phase = models.CharField(max_length=50, default='INIT')
message = models.TextField(blank=True)
status = models.CharField(max_length=20, choices=[
('success', 'Success'),
('error', 'Error'),
('pending', 'Pending'),
], default='pending')
# Timing
duration = models.IntegerField(null=True, blank=True, help_text="Duration in milliseconds")
# Cost tracking
cost = models.DecimalField(max_digits=10, decimal_places=6, default=0.0)
tokens = models.IntegerField(default=0)
# Step tracking
request_steps = models.JSONField(default=list, blank=True)
response_steps = models.JSONField(default=list, blank=True)
# Error tracking
error = models.TextField(null=True, blank=True)
# Data
payload = models.JSONField(null=True, blank=True)
result = models.JSONField(null=True, blank=True)
class Meta:
db_table = 'igny8_ai_task_logs'
ordering = ['-created_at']
indexes = [
models.Index(fields=['task_id']),
models.Index(fields=['function_name', 'account']),
models.Index(fields=['status', 'created_at']),
]
def __str__(self):
return f"{self.function_name} - {self.status} - {self.created_at}"

View File

@@ -0,0 +1,116 @@
"""
AI Settings - Centralized model configurations and limits
"""
from typing import Dict, Any
# Model configurations for each AI function
MODEL_CONFIG = {
"auto_cluster": {
"model": "gpt-4o-mini",
"max_tokens": 3000,
"temperature": 0.7,
"response_format": {"type": "json_object"}, # Auto-enabled for JSON mode models
},
"generate_ideas": {
"model": "gpt-4.1",
"max_tokens": 4000,
"temperature": 0.7,
"response_format": {"type": "json_object"}, # JSON output
},
"generate_content": {
"model": "gpt-4.1",
"max_tokens": 8000,
"temperature": 0.7,
"response_format": {"type": "json_object"}, # JSON output
},
"generate_images": {
"model": "dall-e-3",
"size": "1024x1024",
"provider": "openai",
},
"extract_image_prompts": {
"model": "gpt-4o-mini",
"max_tokens": 1000,
"temperature": 0.7,
"response_format": {"type": "json_object"},
},
}
# Function name aliases (for backward compatibility)
FUNCTION_ALIASES = {
"cluster_keywords": "auto_cluster",
"auto_cluster_keywords": "auto_cluster",
"auto_generate_ideas": "generate_ideas",
"auto_generate_content": "generate_content",
"auto_generate_images": "generate_images",
}
def get_model_config(function_name: str, account=None) -> Dict[str, Any]:
"""
Get model configuration for an AI function.
Reads model from IntegrationSettings if account is provided, otherwise uses defaults.
Args:
function_name: AI function name (e.g., 'auto_cluster', 'generate_ideas')
account: Optional account object to read model from IntegrationSettings
Returns:
Dict with model, max_tokens, temperature, etc.
"""
# Check aliases first
actual_name = FUNCTION_ALIASES.get(function_name, function_name)
# Get base config
config = MODEL_CONFIG.get(actual_name, {}).copy()
# Try to get model from IntegrationSettings if account is provided
model_from_settings = None
if account:
try:
from igny8_core.modules.system.models import IntegrationSettings
openai_settings = IntegrationSettings.objects.filter(
integration_type='openai',
account=account,
is_active=True
).first()
if openai_settings and openai_settings.config:
model_from_settings = openai_settings.config.get('model')
if model_from_settings:
# Validate model is in our supported list
from igny8_core.utils.ai_processor import MODEL_RATES
if model_from_settings in MODEL_RATES:
config['model'] = model_from_settings
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Could not load model from IntegrationSettings: {e}", exc_info=True)
# Merge with defaults
default_config = {
"model": "gpt-4.1",
"max_tokens": 4000,
"temperature": 0.7,
"response_format": None,
}
return {**default_config, **config}
def get_model(function_name: str) -> str:
"""Get model name for function"""
config = get_model_config(function_name)
return config.get("model", "gpt-4.1")
def get_max_tokens(function_name: str) -> int:
"""Get max tokens for function"""
config = get_model_config(function_name)
return config.get("max_tokens", 4000)
def get_temperature(function_name: str) -> float:
"""Get temperature for function"""
config = get_model_config(function_name)
return config.get("temperature", 0.7)

View File

@@ -0,0 +1,347 @@
"""
Progress and Step Tracking utilities for AI framework
"""
import time
import logging
from typing import List, Dict, Any, Optional, Callable
from datetime import datetime
from igny8_core.ai.types import StepLog, ProgressState
from igny8_core.ai.constants import DEBUG_MODE
logger = logging.getLogger(__name__)
class StepTracker:
"""Tracks detailed request and response steps for debugging"""
def __init__(self, function_name: str):
self.function_name = function_name
self.request_steps: List[Dict] = []
self.response_steps: List[Dict] = []
self.step_counter = 0
def add_request_step(
self,
step_name: str,
status: str = 'success',
message: str = '',
error: str = None,
duration: int = None
) -> Dict:
"""Add a request step with automatic timing"""
self.step_counter += 1
step = {
'stepNumber': self.step_counter,
'stepName': step_name,
'functionName': self.function_name,
'status': status,
'message': message,
'duration': duration
}
if error:
step['error'] = error
self.request_steps.append(step)
return step
def add_response_step(
self,
step_name: str,
status: str = 'success',
message: str = '',
error: str = None,
duration: int = None
) -> Dict:
"""Add a response step with automatic timing"""
self.step_counter += 1
step = {
'stepNumber': self.step_counter,
'stepName': step_name,
'functionName': self.function_name,
'status': status,
'message': message,
'duration': duration
}
if error:
step['error'] = error
self.response_steps.append(step)
return step
def get_meta(self) -> Dict:
"""Get metadata for progress callback"""
return {
'request_steps': self.request_steps,
'response_steps': self.response_steps
}
class ProgressTracker:
"""Tracks progress updates for AI tasks"""
def __init__(self, celery_task=None):
self.task = celery_task
self.current_phase = 'INIT'
self.current_message = 'Initializing...'
self.current_percentage = 0
self.start_time = time.time()
self.current = 0
self.total = 0
def update(
self,
phase: str,
percentage: int,
message: str,
current: int = None,
total: int = None,
current_item: str = None,
meta: Dict = None
):
"""Update progress with consistent format"""
self.current_phase = phase
self.current_message = message
self.current_percentage = percentage
if current is not None:
self.current = current
if total is not None:
self.total = total
progress_meta = {
'phase': phase,
'percentage': percentage,
'message': message,
'current': self.current,
'total': self.total,
}
if current_item:
progress_meta['current_item'] = current_item
if meta:
progress_meta.update(meta)
# Update Celery task state if available
if self.task:
try:
self.task.update_state(
state='PROGRESS',
meta=progress_meta
)
except Exception as e:
logger.warning(f"Failed to update Celery task state: {e}")
logger.info(f"[{phase}] {percentage}%: {message}")
def set_phase(self, phase: str, percentage: int, message: str, meta: Dict = None):
"""Set progress phase"""
self.update(phase, percentage, message, meta=meta)
def complete(self, message: str = "Task complete!", meta: Dict = None):
"""Mark task as complete"""
final_meta = {
'phase': 'DONE',
'percentage': 100,
'message': message,
'status': 'success'
}
if meta:
final_meta.update(meta)
if self.task:
try:
self.task.update_state(
state='SUCCESS',
meta=final_meta
)
except Exception as e:
logger.warning(f"Failed to update Celery task state: {e}")
def error(self, error_message: str, meta: Dict = None):
"""Mark task as failed"""
error_meta = {
'phase': 'ERROR',
'percentage': 0,
'message': f'Error: {error_message}',
'status': 'error',
'error': error_message
}
if meta:
error_meta.update(meta)
if self.task:
try:
self.task.update_state(
state='FAILURE',
meta=error_meta
)
except Exception as e:
logger.warning(f"Failed to update Celery task state: {e}")
def get_duration(self) -> int:
"""Get elapsed time in milliseconds"""
return int((time.time() - self.start_time) * 1000)
def update_ai_progress(self, state: str, meta: Dict):
"""Callback for AI processor progress updates"""
if isinstance(meta, dict):
percentage = meta.get('percentage', self.current_percentage)
message = meta.get('message', self.current_message)
phase = meta.get('phase', self.current_phase)
self.update(phase, percentage, message, meta=meta)
class CostTracker:
"""Tracks API costs and token usage"""
def __init__(self):
self.total_cost = 0.0
self.total_tokens = 0
self.operations = []
def record(self, function_name: str, cost: float, tokens: int, model: str = None):
"""Record an API call cost"""
self.total_cost += cost
self.total_tokens += tokens
self.operations.append({
'function': function_name,
'cost': cost,
'tokens': tokens,
'model': model
})
def get_total(self) -> float:
"""Get total cost"""
return self.total_cost
def get_total_tokens(self) -> int:
"""Get total tokens"""
return self.total_tokens
def get_operations(self) -> List[Dict]:
"""Get all operations"""
return self.operations
class ConsoleStepTracker:
"""
Lightweight console-based step tracker for AI functions.
Logs each step to console with timestamps and clear labels.
Only logs if DEBUG_MODE is True.
"""
def __init__(self, function_name: str):
self.function_name = function_name
self.start_time = time.time()
self.steps = []
self.current_phase = None
# Debug: Verify DEBUG_MODE is enabled
import sys
if DEBUG_MODE:
init_msg = f"[DEBUG] ConsoleStepTracker initialized for '{function_name}' - DEBUG_MODE is ENABLED"
logger.info(init_msg)
print(init_msg, flush=True, file=sys.stdout)
else:
init_msg = f"[WARNING] ConsoleStepTracker initialized for '{function_name}' - DEBUG_MODE is DISABLED"
logger.warning(init_msg)
print(init_msg, flush=True, file=sys.stdout)
def _log(self, phase: str, message: str, status: str = 'info'):
"""Internal logging method that checks DEBUG_MODE"""
if not DEBUG_MODE:
return
import sys
timestamp = datetime.now().strftime('%H:%M:%S')
phase_label = phase.upper()
if status == 'error':
log_msg = f"[{timestamp}] [{self.function_name}] [{phase_label}] [ERROR] {message}"
# Use logger.error for errors so they're always visible
logger.error(log_msg)
elif status == 'success':
log_msg = f"[{timestamp}] [{self.function_name}] [{phase_label}] ✅ {message}"
logger.info(log_msg)
else:
log_msg = f"[{timestamp}] [{self.function_name}] [{phase_label}] {message}"
logger.info(log_msg)
# Also print to stdout for immediate visibility (works in Celery worker logs)
print(log_msg, flush=True, file=sys.stdout)
self.steps.append({
'timestamp': timestamp,
'phase': phase,
'message': message,
'status': status
})
self.current_phase = phase
def init(self, message: str = "Task started"):
"""Log initialization phase"""
self._log('INIT', message)
def prep(self, message: str):
"""Log preparation phase"""
self._log('PREP', message)
def ai_call(self, message: str):
"""Log AI call phase"""
self._log('AI_CALL', message)
def parse(self, message: str):
"""Log parsing phase"""
self._log('PARSE', message)
def save(self, message: str):
"""Log save phase"""
self._log('SAVE', message)
def done(self, message: str = "Execution completed"):
"""Log completion"""
duration = time.time() - self.start_time
self._log('DONE', f"{message} (Duration: {duration:.2f}s)", status='success')
if DEBUG_MODE:
import sys
complete_msg = f"[{self.function_name}] === AI Task Complete ==="
logger.info(complete_msg)
print(complete_msg, flush=True, file=sys.stdout)
def error(self, error_type: str, message: str, exception: Exception = None):
"""Log error with standardized format"""
error_msg = f"{error_type} {message}"
if exception:
error_msg += f" ({type(exception).__name__})"
self._log(self.current_phase or 'ERROR', error_msg, status='error')
if DEBUG_MODE and exception:
import sys
import traceback
error_trace_msg = f"[{self.function_name}] [ERROR] Stack trace:"
logger.error(error_trace_msg, exc_info=exception)
print(error_trace_msg, flush=True, file=sys.stdout)
traceback.print_exc(file=sys.stdout)
def retry(self, attempt: int, max_attempts: int, reason: str = ""):
"""Log retry attempt"""
msg = f"Retry attempt {attempt}/{max_attempts}"
if reason:
msg += f" {reason}"
self._log('AI_CALL', msg, status='info')
def timeout(self, timeout_seconds: int):
"""Log timeout"""
self.error('Timeout', f"Request timeout after {timeout_seconds}s")
def rate_limit(self, retry_after: str):
"""Log rate limit"""
self.error('RateLimit', f"OpenAI rate limit hit, retry in {retry_after}s")
def malformed_json(self, details: str = ""):
"""Log JSON parsing error"""
msg = "Failed to parse model response: Unexpected JSON"
if details:
msg += f" {details}"
self.error('MalformedJSON', msg)

View File

@@ -0,0 +1,5 @@
"""
AI Templates
Template files for reference when creating new AI functions.
"""

View File

@@ -0,0 +1,281 @@
"""
AI Functions Template
Template/Reference file showing the common pattern used by auto_cluster, generate_ideas, and generate_content.
This is a reference template - do not modify existing functions, use this as a guide for new functions.
"""
import logging
from typing import Dict, List, Any, Optional
from igny8_core.auth.models import Account
from igny8_core.ai.helpers.base import BaseAIFunction
from igny8_core.ai.helpers.ai_core import AICore
from igny8_core.ai.helpers.tracker import ConsoleStepTracker
from igny8_core.ai.helpers.settings import get_model_config
logger = logging.getLogger(__name__)
def ai_function_core_template(
function_class: BaseAIFunction,
function_name: str,
payload: Dict[str, Any],
account_id: Optional[int] = None,
progress_callback: Optional[callable] = None,
**kwargs
) -> Dict[str, Any]:
"""
Template for AI function core logic (legacy function signature pattern).
This template shows the common pattern used by:
- generate_ideas_core
- generate_content_core
- auto_cluster (via engine, but similar pattern)
Usage Example:
def my_function_core(item_id: int, account_id: int = None, progress_callback=None):
fn = MyFunctionClass()
payload = {'ids': [item_id]}
return ai_function_core_template(
function_class=fn,
function_name='my_function',
payload=payload,
account_id=account_id,
progress_callback=progress_callback
)
Args:
function_class: Instance of the AI function class (e.g., GenerateIdeasFunction())
function_name: Function name for config/tracking (e.g., 'generate_ideas')
payload: Payload dict with 'ids' and other function-specific data
account_id: Optional account ID for account isolation
progress_callback: Optional progress callback for Celery tasks
**kwargs: Additional function-specific parameters
Returns:
Dict with 'success', function-specific result fields, 'message', etc.
"""
# Initialize tracker
tracker = ConsoleStepTracker(function_name)
tracker.init("Task started")
try:
# Load account
account = None
if account_id:
account = Account.objects.get(id=account_id)
tracker.prep("Loading account data...")
# Store account on function instance
function_class.account = account
# Validate
tracker.prep("Validating input...")
validated = function_class.validate(payload, account)
if not validated['valid']:
tracker.error('ValidationError', validated['error'])
return {'success': False, 'error': validated['error']}
# Prepare data
tracker.prep("Preparing data...")
data = function_class.prepare(payload, account)
# Build prompt
tracker.prep("Building prompt...")
prompt = function_class.build_prompt(data, account)
# Get model config from settings
model_config = get_model_config(function_name)
# Generate function_id for tracking (ai_ prefix with function name)
function_id = f"ai_{function_name}"
# Call AI using centralized request handler
ai_core = AICore(account=account)
result = ai_core.run_ai_request(
prompt=prompt,
model=model_config.get('model'),
max_tokens=model_config.get('max_tokens'),
temperature=model_config.get('temperature'),
response_format=model_config.get('response_format'),
function_name=function_name,
function_id=function_id,
tracker=tracker
)
if result.get('error'):
return {'success': False, 'error': result['error']}
# Parse response
tracker.parse("Parsing AI response...")
parsed = function_class.parse_response(result['content'], tracker)
if not parsed:
tracker.error('ParseError', 'No data parsed from AI response')
return {'success': False, 'error': 'No data parsed from AI response'}
# Handle list responses
if isinstance(parsed, list):
parsed_count = len(parsed)
tracker.parse(f"Parsed {parsed_count} item(s)")
else:
parsed_count = 1
tracker.parse("Parsed response")
# Save output
tracker.save("Saving to database...")
save_result = function_class.save_output(parsed, data, account, step_tracker=tracker)
tracker.save(f"Saved {save_result.get('count', 0)} item(s)")
# Build success message
if isinstance(parsed, list) and len(parsed) > 0:
first_item = parsed[0]
item_name = first_item.get('title') or first_item.get('name') or 'item'
tracker.done(f"Successfully created {item_name}")
message = f"Successfully created {item_name}"
else:
tracker.done("Task completed successfully")
message = "Task completed successfully"
return {
'success': True,
**save_result,
'message': message
}
except Exception as e:
tracker.error('Exception', str(e), e)
logger.error(f"Error in {function_name}_core: {str(e)}", exc_info=True)
return {'success': False, 'error': str(e)}
def ai_function_batch_template(
function_class: BaseAIFunction,
function_name: str,
payload: Dict[str, Any],
account_id: Optional[int] = None,
progress_callback: Optional[callable] = None,
**kwargs
) -> Dict[str, Any]:
"""
Template for AI function batch processing (like generate_content_core).
This template shows the pattern for functions that process multiple items in a loop.
Usage Example:
def my_batch_function_core(item_ids: List[int], account_id: int = None, progress_callback=None):
fn = MyFunctionClass()
payload = {'ids': item_ids}
return ai_function_batch_template(
function_class=fn,
function_name='my_function',
payload=payload,
account_id=account_id,
progress_callback=progress_callback
)
Args:
function_class: Instance of the AI function class
function_name: Function name for config/tracking
payload: Payload dict with 'ids' list
account_id: Optional account ID for account isolation
progress_callback: Optional progress callback for Celery tasks
**kwargs: Additional function-specific parameters
Returns:
Dict with 'success', 'count', 'tasks_updated', 'message', etc.
"""
tracker = ConsoleStepTracker(function_name)
tracker.init("Task started")
try:
# Load account
account = None
if account_id:
account = Account.objects.get(id=account_id)
tracker.prep("Loading account data...")
# Store account on function instance
function_class.account = account
# Validate
tracker.prep("Validating input...")
validated = function_class.validate(payload, account)
if not validated['valid']:
tracker.error('ValidationError', validated['error'])
return {'success': False, 'error': validated['error']}
# Prepare data (returns list of items)
tracker.prep("Preparing data...")
items = function_class.prepare(payload, account)
if not isinstance(items, list):
items = [items]
total_items = len(items)
processed_count = 0
tracker.prep(f"Processing {total_items} item(s)...")
# Get model config once (shared across all items)
model_config = get_model_config(function_name)
# Generate function_id for tracking (ai_ prefix with function name)
function_id = f"ai_{function_name}"
ai_core = AICore(account=account)
# Process each item
for idx, item in enumerate(items):
try:
# Build prompt for this item
prompt = function_class.build_prompt(item if not isinstance(items, list) else [item], account)
# Call AI
result = ai_core.run_ai_request(
prompt=prompt,
model=model_config.get('model'),
max_tokens=model_config.get('max_tokens'),
temperature=model_config.get('temperature'),
response_format=model_config.get('response_format'),
function_name=function_name,
function_id=function_id,
tracker=tracker
)
if result.get('error'):
logger.error(f"AI error for item {idx + 1}/{total_items}: {result['error']}")
continue
# Parse response
parsed = function_class.parse_response(result['content'], tracker)
if not parsed:
logger.warning(f"No data parsed for item {idx + 1}/{total_items}")
continue
# Save output
save_result = function_class.save_output(
parsed,
item if not isinstance(items, list) else [item],
account,
step_tracker=tracker
)
processed_count += save_result.get('count', 0) or save_result.get('tasks_updated', 0) or 0
except Exception as e:
logger.error(f"Error processing item {idx + 1}/{total_items}: {str(e)}", exc_info=True)
continue
tracker.done(f"Processed {processed_count} item(s) successfully")
return {
'success': True,
'count': processed_count,
'tasks_updated': processed_count,
'message': f'Task completed: {processed_count} item(s) processed'
}
except Exception as e:
tracker.error('Exception', str(e), e)
logger.error(f"Error in {function_name}_core: {str(e)}", exc_info=True)
return {'success': False, 'error': str(e)}

View File

@@ -0,0 +1,80 @@
"""
Modal Configuration Templates for AI Functions
Each function uses the same AIProgressModal component with different configs.
"""
# Modal configuration templates for each AI function
MODAL_CONFIGS = {
'auto_cluster': {
'title': 'Auto Cluster Keywords',
'function_id': 'ai_auto_cluster',
'success_title': 'Clustering Complete!',
'success_message_template': 'Successfully created {clusters_created} clusters and updated {keywords_updated} keywords.',
'error_title': 'Clustering Failed',
'error_message_template': 'An error occurred while clustering keywords. Please try again.',
},
'generate_ideas': {
'title': 'Generating Ideas',
'function_id': 'ai_generate_ideas',
'success_title': 'Ideas Generated!',
'success_message_template': 'Successfully generated {ideas_created} content idea(s).',
'error_title': 'Idea Generation Failed',
'error_message_template': 'An error occurred while generating ideas. Please try again.',
},
'generate_content': {
'title': 'Generating Content',
'function_id': 'ai_generate_content',
'success_title': 'Content Generated!',
'success_message_template': 'Successfully generated content for {tasks_updated} task(s).',
'error_title': 'Content Generation Failed',
'error_message_template': 'An error occurred while generating content. Please try again.',
},
}
# Legacy function IDs (for backward compatibility)
LEGACY_FUNCTION_IDS = {
'generate_ideas': 'ai_generate_ideas',
'generate_content': 'ai_generate_content',
}
def get_modal_config(function_name: str, is_legacy: bool = False) -> dict:
"""
Get modal configuration for an AI function.
Args:
function_name: Function name (e.g., 'auto_cluster', 'generate_ideas', 'generate_content')
is_legacy: Whether this is a legacy function path
Returns:
Dict with modal configuration
"""
config = MODAL_CONFIGS.get(function_name, {}).copy()
# Override function_id for legacy paths
if is_legacy and function_name in LEGACY_FUNCTION_IDS:
config['function_id'] = LEGACY_FUNCTION_IDS[function_name]
return config
def format_success_message(function_name: str, result: dict) -> str:
"""
Format success message based on function result.
Args:
function_name: Function name
result: Result dict from function execution
Returns:
Formatted success message
"""
config = MODAL_CONFIGS.get(function_name, {})
template = config.get('success_message_template', 'Task completed successfully.')
try:
return template.format(**result)
except KeyError:
# Fallback if template variables don't match
return config.get('success_message_template', 'Task completed successfully.')

View File

@@ -0,0 +1,458 @@
import React, { useEffect, useRef } from 'react';
import { Modal } from '../ui/modal';
import { ProgressBar } from '../ui/progress';
import Button from '../ui/button/Button';
export interface AIProgressModalProps {
isOpen: boolean;
title: string;
percentage: number; // 0-100
status: 'pending' | 'processing' | 'completed' | 'error';
message: string;
details?: {
current: number;
total: number;
completed: number;
currentItem?: string;
phase?: string;
};
onClose?: () => void;
onCancel?: () => void;
taskId?: string;
functionId?: string; // AI function ID for tracking (e.g., "ai-cluster-01")
stepLogs?: Array<{
stepNumber: number;
stepName: string;
status: string;
message: string;
timestamp?: number;
}>; // Step logs for debugging
config?: {
successTitle?: string;
successMessage?: string;
errorTitle?: string;
errorMessage?: string;
};
}
// Generate modal instance ID (increments per modal instance)
let modalInstanceCounter = 0;
const getModalInstanceId = () => {
modalInstanceCounter++;
return `modal-${String(modalInstanceCounter).padStart(2, '0')}`;
};
export default function AIProgressModal({
isOpen,
title,
percentage,
status,
message,
details,
onClose,
onCancel,
taskId,
functionId,
stepLogs = [],
config = {},
}: AIProgressModalProps) {
// Generate modal instance ID on first render
const modalInstanceIdRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!modalInstanceIdRef.current) {
modalInstanceIdRef.current = getModalInstanceId();
}
}, []);
const modalInstanceId = modalInstanceIdRef.current || 'modal-01';
// Build full function ID with modal instance
const fullFunctionId = functionId ? `${functionId}-${modalInstanceId}` : null;
// Determine color based on status
const getProgressColor = (): 'primary' | 'success' | 'error' | 'warning' => {
if (status === 'error') return 'error';
if (status === 'completed') return 'success';
if (status === 'processing') return 'primary';
return 'primary';
};
// Success icon (from AlertModal style)
const SuccessIcon = () => (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light green flower-like outer shape with rounded petals */}
<div
className="absolute inset-0 bg-success-100 rounded-full"
style={{
clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
width: '80px',
height: '80px'
}}
/>
{/* Dark green inner circle */}
<div className="relative bg-success-600 rounded-full w-16 h-16 flex items-center justify-center shadow-lg">
<svg
className="w-8 h-8 text-white"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
);
// Error icon
const ErrorIcon = () => (
<div className="relative flex items-center justify-center w-24 h-24 mx-auto mb-6">
{/* Light red cloud-like background */}
<div
className="absolute inset-0 bg-error-100 rounded-full blur-2xl opacity-50"
style={{
width: '90px',
height: '90px',
transform: 'scale(1.1)'
}}
/>
{/* Light red circle with red X */}
<div className="relative bg-error-100 rounded-full w-16 h-16 flex items-center justify-center shadow-lg">
<svg
className="w-10 h-10 text-error-500"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fillRule="evenodd"
d="M18.364 5.636a1 1 0 010 1.414L13.414 12l4.95 4.95a1 1 0 11-1.414 1.414L12 13.414l-4.95 4.95a1 1 0 01-1.414-1.414L10.586 12 5.636 7.05a1 1 0 011.414-1.414L12 10.586l4.95-4.95a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
);
// Processing spinner
const ProcessingIcon = () => (
<div className="flex items-center justify-center w-16 h-16 mx-auto mb-6">
<svg
className="w-16 h-16 text-brand-500 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
);
// Show completion screen with big success icon
if (status === 'completed') {
return (
<Modal
isOpen={isOpen}
onClose={onClose || (() => {})}
className="max-w-md"
showCloseButton={true}
>
<div className="px-8 py-10 text-center">
{/* Big Success Icon */}
<SuccessIcon />
{/* Title */}
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-4">
{config.successTitle || title || 'Task Completed!'}
</h2>
{/* Message */}
<p className="text-gray-600 dark:text-gray-400 mb-6 text-sm leading-relaxed">
{config.successMessage || message}
</p>
{/* Details if available */}
{details && details.total > 0 && (
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-semibold text-gray-900 dark:text-white">
{details.completed || details.current}
</span>
{' / '}
<span className="text-gray-500 dark:text-gray-400">
{details.total}
</span>
{' items completed'}
</div>
</div>
)}
{/* Function ID and Task ID (for debugging) */}
{(fullFunctionId || taskId) && (
<div className="mb-6 space-y-1 text-xs text-gray-400 dark:text-gray-600">
{fullFunctionId && <div>Function ID: {fullFunctionId}</div>}
{taskId && <div>Task ID: {taskId}</div>}
</div>
)}
{/* Step Logs / Debug Logs */}
{stepLogs.length > 0 && (
<div className="mb-6 max-h-48 overflow-y-auto bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300">
Step Logs
</h4>
<span className="text-xs text-gray-500 dark:text-gray-400">
{stepLogs.length} step{stepLogs.length !== 1 ? 's' : ''}
</span>
</div>
<div className="space-y-1">
{stepLogs.map((step, index) => (
<div
key={index}
className={`text-xs p-2 rounded border ${
step.status === 'success'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: step.status === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-300'
}`}
>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold">
[{step.stepNumber}]
</span>
<span className="font-semibold">{step.stepName}:</span>
<span>{step.message}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Close Button */}
<div className="flex justify-center">
<button
onClick={onClose}
className="px-6 py-3 rounded-lg font-medium text-sm transition-colors shadow-sm bg-success-500 hover:bg-success-600 text-white"
>
Close
</button>
</div>
</div>
</Modal>
);
}
// Show error screen with big error icon
if (status === 'error') {
return (
<Modal
isOpen={isOpen}
onClose={onClose || (() => {})}
className="max-w-md"
showCloseButton={true}
>
<div className="px-8 py-10 text-center">
{/* Big Error Icon */}
<ErrorIcon />
{/* Title */}
<h2 className="text-2xl font-bold text-gray-800 dark:text-white mb-4">
{config.errorTitle || 'Error Occurred'}
</h2>
{/* Message */}
<p className="text-gray-600 dark:text-gray-400 mb-6 text-sm leading-relaxed">
{config.errorMessage || message}
</p>
{/* Function ID and Task ID (for debugging) */}
{(fullFunctionId || taskId) && (
<div className="mb-6 space-y-1 text-xs text-gray-400 dark:text-gray-600">
{fullFunctionId && <div>Function ID: {fullFunctionId}</div>}
{taskId && <div>Task ID: {taskId}</div>}
</div>
)}
{/* Step Logs / Debug Logs */}
{stepLogs.length > 0 && (
<div className="mb-6 max-h-48 overflow-y-auto bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300">
Step Logs
</h4>
<span className="text-xs text-gray-500 dark:text-gray-400">
{stepLogs.length} step{stepLogs.length !== 1 ? 's' : ''}
</span>
</div>
<div className="space-y-1">
{stepLogs.map((step, index) => (
<div
key={index}
className={`text-xs p-2 rounded border ${
step.status === 'success'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: step.status === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-300'
}`}
>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold">
[{step.stepNumber}]
</span>
<span className="font-semibold">{step.stepName}:</span>
<span>{step.message}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Close Button */}
<div className="flex justify-center">
<button
onClick={onClose}
className="px-6 py-3 rounded-lg font-medium text-sm transition-colors shadow-sm bg-error-500 hover:bg-error-600 text-white"
>
Close
</button>
</div>
</div>
</Modal>
);
}
// Processing/Pending state - show progress modal
return (
<Modal
isOpen={isOpen}
onClose={onClose || (() => {})}
className="max-w-lg"
showCloseButton={false}
>
<div className="p-6 min-h-[300px]">
{/* Header with Processing Icon */}
<div className="flex flex-col items-center mb-6">
<ProcessingIcon />
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2 text-center">
{title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
{message}
</p>
</div>
{/* Progress Bar */}
<div className="mb-6">
<ProgressBar
value={percentage}
color={getProgressColor()}
size="lg"
showLabel={true}
label={`${Math.round(percentage)}%`}
/>
</div>
{/* Details (current/total) */}
{details && details.total > 0 && (
<div className="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
Progress
</span>
<span className="font-semibold text-gray-900 dark:text-white">
{details.current || details.completed || 0} / {details.total}
</span>
</div>
{details.currentItem && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 truncate">
Current: {details.currentItem}
</div>
)}
</div>
)}
{/* Function ID and Task ID (for debugging) */}
{(fullFunctionId || taskId) && (
<div className="mb-4 space-y-1 text-xs text-gray-400 dark:text-gray-600">
{fullFunctionId && (
<div>Function ID: {fullFunctionId}</div>
)}
{taskId && (
<div>Task ID: {taskId}</div>
)}
</div>
)}
{/* Step Logs / Debug Logs */}
{stepLogs.length > 0 && (
<div className="mb-4 max-h-48 overflow-y-auto bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300">
Step Logs
</h4>
<span className="text-xs text-gray-500 dark:text-gray-400">
{stepLogs.length} step{stepLogs.length !== 1 ? 's' : ''}
</span>
</div>
<div className="space-y-1">
{stepLogs.map((step, index) => (
<div
key={index}
className={`text-xs p-2 rounded border ${
step.status === 'success'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-800 dark:text-green-300'
: step.status === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-300'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-300'
}`}
>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold">
[{step.stepNumber}]
</span>
<span className="font-semibold">{step.stepName}:</span>
<span>{step.message}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-3">
{onCancel && status !== 'completed' && status !== 'error' && (
<Button
variant="secondary"
size="sm"
onClick={onCancel}
disabled={status === 'processing'}
>
Cancel
</Button>
)}
</div>
</div>
</Modal>
);
}