refactored file uploader and bug fixes
This commit is contained in:
parent
8a5c50cacb
commit
3e73259ff3
17 changed files with 1279 additions and 1048 deletions
Binary file not shown.
|
|
@ -563,26 +563,30 @@ def advance_moderator_discussion(focus_group_id):
|
|||
activates_visual_context = True
|
||||
print(f"🎨 ADVANCE DISCUSSION: Will activate visual context for asset: {asset_filename}")
|
||||
|
||||
# Create visual asset metadata for frontend display
|
||||
visual_asset_metadata = None
|
||||
if activates_visual_context and attached_assets and len(attached_assets) > 0:
|
||||
# Create visual asset metadata that frontend expects
|
||||
visual_asset_metadata = {
|
||||
"filename": attached_assets[0], # Use first asset
|
||||
"displayReference": display_reference or attached_assets[0] # Use display reference or filename as fallback
|
||||
}
|
||||
|
||||
message_data = {
|
||||
"text": result["moderator_response"],
|
||||
"type": "question",
|
||||
"senderId": "moderator",
|
||||
"attached_assets": attached_assets,
|
||||
"activates_visual_context": activates_visual_context
|
||||
"activates_visual_context": activates_visual_context,
|
||||
"visual_asset": visual_asset_metadata # Frontend needs this for image display
|
||||
}
|
||||
|
||||
message_id = FocusGroup.add_message(focus_group_id, message_data)
|
||||
|
||||
# Activate visual assets if needed
|
||||
# Visual context activation is handled automatically by FocusGroup.add_message()
|
||||
# when activates_visual_context=True and attached_assets are present
|
||||
if activates_visual_context and attached_assets:
|
||||
try:
|
||||
success = FocusGroup._activate_visual_assets(focus_group_id, attached_assets, message_id)
|
||||
if success:
|
||||
print(f"✅ ADVANCE DISCUSSION: Visual context activated for {attached_assets}")
|
||||
else:
|
||||
print(f"⚠️ ADVANCE DISCUSSION: Failed to activate visual context for {attached_assets}")
|
||||
except Exception as activation_error:
|
||||
print(f"⚠️ ADVANCE DISCUSSION: Error activating visual context: {activation_error}")
|
||||
print(f"✅ ADVANCE DISCUSSION: Visual context activated for {attached_assets}")
|
||||
|
||||
if message_id:
|
||||
result["message_id"] = message_id
|
||||
|
|
@ -838,13 +842,8 @@ def stop_autonomous_conversation(focus_group_id):
|
|||
|
||||
current_app.logger.info(f"Signaled autonomous conversation to stop for focus group {focus_group_id}: {reason}")
|
||||
|
||||
# Log the manual mode start event (AI mode stopped)
|
||||
try:
|
||||
user_id = get_jwt_identity() # Get user ID if available
|
||||
mode_event_id = FocusGroup.add_mode_event(focus_group_id, 'manual_mode_started', user_id)
|
||||
current_app.logger.info(f"Logged manual mode start event: {mode_event_id}")
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Failed to log manual mode start event: {e}")
|
||||
# Mode events are now handled by AIModeratorService.end_session_with_concluding_statement()
|
||||
# to prevent duplicate mode event indicators
|
||||
|
||||
# Return immediately with a success response like start_autonomous_conversation
|
||||
result = {
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -722,23 +722,17 @@ class AIModeratorService:
|
|||
'status': 'completed'
|
||||
})
|
||||
|
||||
# Add mode event for AI-driven session conclusions
|
||||
# This includes auto_complete, natural_completion, discussion_guide_completed, etc.
|
||||
ai_driven_reasons = [
|
||||
'auto_complete', 'natural_completion', 'discussion_guide_completed',
|
||||
'action_limit_reached', 'time_limit'
|
||||
]
|
||||
# Add mode event for all AI session conclusions
|
||||
# This includes auto_complete, natural_completion, discussion_guide_completed, manual_stop, etc.
|
||||
mode_event_id = FocusGroup.add_mode_event(
|
||||
focus_group_id=focus_group_id,
|
||||
event_type='ai_session_concluded'
|
||||
)
|
||||
|
||||
if reason in ai_driven_reasons:
|
||||
mode_event_id = FocusGroup.add_mode_event(
|
||||
focus_group_id=focus_group_id,
|
||||
event_type='ai_session_concluded'
|
||||
)
|
||||
|
||||
if mode_event_id:
|
||||
print(f"🎯 Added AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
|
||||
else:
|
||||
print(f"Warning: Failed to add AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
|
||||
if mode_event_id:
|
||||
print(f"🎯 Added AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
|
||||
else:
|
||||
print(f"Warning: Failed to add AI session concluded mode event for focus group {focus_group_id} (reason: {reason})")
|
||||
|
||||
print(f"🎬 Session ended for focus group {focus_group_id} with reason: {reason}")
|
||||
|
||||
|
|
|
|||
|
|
@ -156,14 +156,8 @@ class AutonomousConversationController:
|
|||
# GPT-5 fix: Emit AI status update to notify frontend of completion
|
||||
# The FocusGroup.update() will trigger the websocket event automatically
|
||||
|
||||
# Log the mode change event for automatic completion
|
||||
completion_events = ['completed', 'discussion_guide_completed', 'natural_completion']
|
||||
if reason in completion_events:
|
||||
mode_event_id = FocusGroup.add_mode_event(self.focus_group_id, 'ai_session_concluded', None)
|
||||
self.logger.info(f"Logged AI session conclusion event: {mode_event_id}")
|
||||
else:
|
||||
mode_event_id = FocusGroup.add_mode_event(self.focus_group_id, 'manual_mode_started', None)
|
||||
self.logger.info(f"Logged manual mode start event: {mode_event_id}")
|
||||
# Mode events are now handled by AIModeratorService.end_session_with_concluding_statement()
|
||||
# to prevent duplicate mode event indicators
|
||||
|
||||
self.logger.info(f"Stopped autonomous conversation for focus group {self.focus_group_id}: {reason}")
|
||||
|
||||
|
|
@ -782,22 +776,21 @@ class AutonomousConversationController:
|
|||
async def _add_moderator_message(self, content: str, message_type: str) -> Optional[str]:
|
||||
"""Add a moderator message to the conversation."""
|
||||
try:
|
||||
# Check if this message corresponds to a creative review activity
|
||||
# Initialize image detection variables
|
||||
attached_assets = []
|
||||
activates_visual_context = False
|
||||
display_reference = None
|
||||
|
||||
# Check if we're currently at a creative review activity in the discussion guide
|
||||
# Check if current discussion guide item has image attachments
|
||||
try:
|
||||
from app.services.ai_moderator_service import AIModeratorService
|
||||
from app.services.focus_group_response_service import extract_asset_filename_from_content
|
||||
|
||||
print(f"🔍 MODERATOR MESSAGE DEBUG: Checking for creative review activity")
|
||||
print(f"🔍 Message content: {content[:100]}...")
|
||||
print(f"🔍 Checking current discussion guide item for image attachments")
|
||||
|
||||
moderator_status = AIModeratorService.get_moderator_status(self.focus_group_id)
|
||||
print(f"🔍 Moderator status: {moderator_status}")
|
||||
|
||||
current_item = None
|
||||
|
||||
if moderator_status and 'moderator_position' in moderator_status:
|
||||
focus_group = FocusGroup.find_by_id(self.focus_group_id)
|
||||
if focus_group:
|
||||
|
|
@ -810,12 +803,8 @@ class AutonomousConversationController:
|
|||
item_type = pos.get('item_type', 'activity')
|
||||
subsection_idx = pos.get('subsection_index')
|
||||
|
||||
print(f"🔍 Position: section_idx={section_idx}, item_idx={item_idx}, item_type={item_type}, subsection_idx={subsection_idx}")
|
||||
print(f"🔍 Total sections: {len(sections)}")
|
||||
|
||||
if section_idx < len(sections):
|
||||
section = sections[section_idx]
|
||||
print(f"🔍 Section found: {section.get('title', 'No title')}")
|
||||
|
||||
# Get current item from subsection or section
|
||||
if subsection_idx is not None and section.get('subsections'):
|
||||
|
|
@ -831,39 +820,22 @@ class AutonomousConversationController:
|
|||
if item_idx < len(items):
|
||||
current_item = items[item_idx]
|
||||
|
||||
# Check if item has an image attached (any item type)
|
||||
print(f"🔍 Item to check: {current_item}")
|
||||
# Check if current item has visual asset metadata
|
||||
if current_item:
|
||||
print(f"🔍 Item type: {current_item.get('type')}")
|
||||
|
||||
# Try to get asset info from metadata (new metadata-driven approach)
|
||||
asset_filename = None
|
||||
display_reference = None
|
||||
print(f"🔍 Current item: {current_item.get('content', '')[:50]}...")
|
||||
|
||||
metadata = current_item.get('metadata', {})
|
||||
visual_asset = metadata.get('visual_asset')
|
||||
|
||||
if visual_asset:
|
||||
# Use metadata (preferred method)
|
||||
# Found image in metadata - use it
|
||||
asset_filename = visual_asset.get('filename')
|
||||
display_reference = visual_asset.get('display_reference')
|
||||
print(f"🔍 Found asset metadata: {display_reference} -> {asset_filename}")
|
||||
else:
|
||||
# Fallback to content parsing (legacy support)
|
||||
activity_content = current_item.get('content', '')
|
||||
asset_filename = extract_asset_filename_from_content(activity_content)
|
||||
print(f"🔍 Legacy asset filename extraction: {asset_filename}")
|
||||
|
||||
if asset_filename:
|
||||
print(f"🔍 Item with image found! Type: {current_item.get('type')}")
|
||||
print(f"🔍 Asset: {display_reference or 'legacy'} -> {asset_filename}")
|
||||
|
||||
print(f"🎨 Found image in current item metadata: {display_reference} -> {asset_filename}")
|
||||
|
||||
attached_assets = [asset_filename]
|
||||
activates_visual_context = True
|
||||
self.logger.info(f"🎨 Detected creative review activity - will activate visual asset: {asset_filename}")
|
||||
print(f"🎨 MODERATOR MESSAGE: Activating visual context for asset: {asset_filename}")
|
||||
print(f"🎨 Activity content: {activity_content}")
|
||||
print(f"🎨 Original moderator message: {content}")
|
||||
|
||||
# Generate AI description and enhance the content
|
||||
try:
|
||||
|
|
@ -887,28 +859,47 @@ class AutonomousConversationController:
|
|||
content = enhanced_content
|
||||
|
||||
print(f"✅ AI MODE: Enhanced moderator message with image description")
|
||||
print(f"🔍 Enhanced message: {content}")
|
||||
|
||||
except ImageDescriptionError as desc_error:
|
||||
print(f"⚠️ AI MODE: Failed to generate image description: {desc_error}")
|
||||
self.logger.warning(f"Failed to generate image description in autonomous mode: {desc_error}")
|
||||
# Continue with original content
|
||||
else:
|
||||
self.logger.warning(f"Creative review activity found but no asset filename detected in content: {activity_content}")
|
||||
|
||||
# No visual asset metadata, try legacy content parsing
|
||||
activity_content = current_item.get('content', '')
|
||||
asset_filename = extract_asset_filename_from_content(activity_content)
|
||||
|
||||
if asset_filename:
|
||||
print(f"🔍 Legacy: Found asset filename in content: {asset_filename}")
|
||||
attached_assets = [asset_filename]
|
||||
activates_visual_context = True
|
||||
else:
|
||||
print(f"🔍 No image attachments found for current item")
|
||||
else:
|
||||
print(f"🔍 No current discussion guide item found")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error checking for creative review activity: {e}")
|
||||
print(f"⚠️ Error checking creative review: {e}")
|
||||
|
||||
print(f"🔍 FINAL RESULT: attached_assets={attached_assets}, activates_visual_context={activates_visual_context}")
|
||||
|
||||
# Create visual asset metadata for frontend display
|
||||
visual_asset_metadata = None
|
||||
if activates_visual_context and attached_assets and len(attached_assets) > 0:
|
||||
# Create visual asset metadata that frontend expects
|
||||
visual_asset_metadata = {
|
||||
"filename": attached_assets[0], # Use first asset
|
||||
"displayReference": display_reference or attached_assets[0] # Use display reference or filename as fallback
|
||||
}
|
||||
|
||||
# Create message data with visual context information
|
||||
message_data = {
|
||||
"text": content,
|
||||
"type": message_type,
|
||||
"senderId": "moderator",
|
||||
"attached_assets": attached_assets,
|
||||
"activates_visual_context": activates_visual_context
|
||||
"activates_visual_context": activates_visual_context,
|
||||
"visual_asset": visual_asset_metadata # Frontend needs this for image display
|
||||
}
|
||||
|
||||
message_id = FocusGroup.add_message(self.focus_group_id, message_data)
|
||||
|
|
@ -916,19 +907,11 @@ class AutonomousConversationController:
|
|||
# GPT-5 fix: Yield after database write to flush WebSocket events
|
||||
await self._yield_to_eventlet()
|
||||
|
||||
# Visual context activation is handled automatically by FocusGroup.add_message()
|
||||
# when activates_visual_context=True and attached_assets are present
|
||||
if activates_visual_context and attached_assets:
|
||||
# Actually activate the visual assets in the focus group for LLM context
|
||||
try:
|
||||
success = FocusGroup._activate_visual_assets(self.focus_group_id, attached_assets, message_id)
|
||||
if success:
|
||||
self.logger.info(f"✅ Added moderator message with visual context activation: {attached_assets}")
|
||||
print(f"✅ VISUAL CONTEXT ACTIVATED: {attached_assets}")
|
||||
else:
|
||||
self.logger.warning(f"⚠️ Failed to activate visual context for: {attached_assets}")
|
||||
print(f"⚠️ Failed to activate visual context for: {attached_assets}")
|
||||
except Exception as activation_error:
|
||||
self.logger.error(f"⚠️ Error activating visual context: {activation_error}")
|
||||
print(f"⚠️ Error activating visual context: {activation_error}")
|
||||
self.logger.info(f"✅ Added moderator message with visual context activation: {attached_assets}")
|
||||
print(f"✅ VISUAL CONTEXT ACTIVATED: {attached_assets}")
|
||||
|
||||
return message_id
|
||||
|
||||
|
|
|
|||
1
dist/assets/index-BLNu9bos.css
vendored
1
dist/assets/index-BLNu9bos.css
vendored
File diff suppressed because one or more lines are too long
715
dist/assets/index-BgDz3VL9.js
vendored
715
dist/assets/index-BgDz3VL9.js
vendored
File diff suppressed because one or more lines are too long
715
dist/assets/index-KiCc6bNq.js
vendored
Normal file
715
dist/assets/index-KiCc6bNq.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-VgChhb1B.css
vendored
Normal file
1
dist/assets/index-VgChhb1B.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
|
|
@ -7,8 +7,8 @@
|
|||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<script type="module" crossorigin src="/semblance/assets/index-BgDz3VL9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/semblance/assets/index-BLNu9bos.css">
|
||||
<script type="module" crossorigin src="/semblance/assets/index-KiCc6bNq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/semblance/assets/index-VgChhb1B.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Upload, UploadCloud, X, FileText, Image as ImageIcon, FileVideo, Loader2, RefreshCw, Edit3, Check } from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Upload, UploadCloud, X, FileText, Image as ImageIcon, FileVideo, Loader2, RefreshCw, Edit3, Check, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { focusGroupsApi } from '@/lib/api';
|
||||
|
||||
// Backend asset interface (matches what we get from the API)
|
||||
|
|
@ -24,35 +26,45 @@ interface LocalAsset {
|
|||
status: 'uploading' | 'uploaded' | 'failed' | 'retry';
|
||||
backendAsset?: BackendAsset;
|
||||
error?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface AssetUploaderProps {
|
||||
onAssetsChange?: (assets: BackendAsset[]) => void;
|
||||
onUploadComplete?: (assets: BackendAsset[]) => void;
|
||||
onUploadError?: (error: unknown) => void;
|
||||
onFilesChange?: (files: File[]) => void; // for non-backend file tracking
|
||||
focusGroupId?: string;
|
||||
disabled?: boolean;
|
||||
maxAssets?: number;
|
||||
allowedTypes?: string[];
|
||||
label?: string;
|
||||
description?: string;
|
||||
maxFileSize?: number; // in MB
|
||||
enableRenaming?: boolean; // whether to show file renaming functionality
|
||||
}
|
||||
|
||||
export default function AssetUploader({
|
||||
onAssetsChange,
|
||||
onUploadComplete,
|
||||
onUploadError,
|
||||
onFilesChange,
|
||||
focusGroupId,
|
||||
disabled = false,
|
||||
maxAssets = 10,
|
||||
allowedTypes = ['image/*', 'application/pdf', 'video/*'],
|
||||
label = 'Upload Assets',
|
||||
description = 'Upload creative assets for testing'
|
||||
description = 'Upload creative assets for testing',
|
||||
maxFileSize = 10, // 10MB default
|
||||
enableRenaming = true
|
||||
}: AssetUploaderProps) {
|
||||
const [localAssets, setLocalAssets] = useState<LocalAsset[]>([]);
|
||||
const [backendAssets, setBackendAssets] = useState<BackendAsset[]>([]);
|
||||
const [editingAsset, setEditingAsset] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState<string>('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropzoneRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch existing backend assets when focusGroupId changes
|
||||
useEffect(() => {
|
||||
|
|
@ -61,6 +73,14 @@ export default function AssetUploader({
|
|||
}
|
||||
}, [focusGroupId]); // fetchBackendAssets is stable and doesn't need to be in deps
|
||||
|
||||
// Notify parent component of file changes for non-backend usage
|
||||
useEffect(() => {
|
||||
if (onFilesChange) {
|
||||
const currentFiles = localAssets.map(asset => asset.file);
|
||||
onFilesChange(currentFiles);
|
||||
}
|
||||
}, [localAssets, onFilesChange]);
|
||||
|
||||
const fetchBackendAssets = async () => {
|
||||
if (!focusGroupId) return;
|
||||
|
||||
|
|
@ -78,8 +98,47 @@ export default function AssetUploader({
|
|||
}
|
||||
};
|
||||
|
||||
// Validate file type and size
|
||||
const validateFile = (file: File): string | null => {
|
||||
// Check file type
|
||||
const isValidType = allowedTypes.some(type => {
|
||||
if (type.includes('*')) {
|
||||
const baseType = type.split('/')[0];
|
||||
return file.type.startsWith(baseType + '/');
|
||||
}
|
||||
return file.type === type;
|
||||
});
|
||||
|
||||
if (!isValidType) {
|
||||
return `File "${file.name}" is not a supported file type. Supported types: ${getFileTypeNames().join(', ')}.`;
|
||||
}
|
||||
|
||||
// Check file size (convert MB to bytes)
|
||||
const maxSizeInBytes = maxFileSize * 1024 * 1024;
|
||||
if (file.size > maxSizeInBytes) {
|
||||
return `File "${file.name}" is too large. Maximum file size: ${maxFileSize}MB.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Get friendly file type names
|
||||
const getFileTypeNames = () => {
|
||||
const typeMap: { [key: string]: string } = {
|
||||
'image/*': 'Images',
|
||||
'application/pdf': 'PDF',
|
||||
'video/*': 'Videos',
|
||||
'text/*': 'Text files',
|
||||
'application/msword': 'Word docs',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word docs',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel files'
|
||||
};
|
||||
|
||||
return allowedTypes.map(type => typeMap[type] || type).filter((v, i, a) => a.indexOf(v) === i);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0 || !focusGroupId) return;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// Check if adding these files would exceed the limit
|
||||
const totalAssets = localAssets.length + backendAssets.length;
|
||||
|
|
@ -88,8 +147,29 @@ export default function AssetUploader({
|
|||
return;
|
||||
}
|
||||
|
||||
// Create local asset objects for immediate UI feedback
|
||||
const newLocalAssets: LocalAsset[] = Array.from(files).map(file => {
|
||||
// Validate each file
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
errors.push(validationError);
|
||||
} else {
|
||||
validFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Show validation errors
|
||||
if (errors.length > 0) {
|
||||
errors.forEach(error => toast.error(error));
|
||||
if (validFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create local asset objects for immediate UI feedback (only for valid files)
|
||||
const newLocalAssets: LocalAsset[] = validFiles.map(file => {
|
||||
const previewUrl = file.type.startsWith('image/')
|
||||
? URL.createObjectURL(file)
|
||||
: undefined;
|
||||
|
|
@ -98,29 +178,50 @@ export default function AssetUploader({
|
|||
id: `local-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
file,
|
||||
previewUrl,
|
||||
status: 'uploading'
|
||||
status: 'uploading',
|
||||
progress: 0
|
||||
};
|
||||
});
|
||||
|
||||
// Add to local state for immediate UI feedback
|
||||
setLocalAssets(prev => [...prev, ...newLocalAssets]);
|
||||
|
||||
// Upload each file to backend
|
||||
// Upload each file to backend (only if focusGroupId exists for backend uploads)
|
||||
for (const localAsset of newLocalAssets) {
|
||||
if (!focusGroupId) {
|
||||
// For generic file upload without backend (like persona creation), just mark as complete
|
||||
setLocalAssets(prev => prev.map(asset =>
|
||||
asset.id === localAsset.id
|
||||
? { ...asset, status: 'uploaded', progress: 100 }
|
||||
: asset
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Simulate progress for better UX
|
||||
const updateProgress = (progress: number) => {
|
||||
setLocalAssets(prev => prev.map(asset =>
|
||||
asset.id === localAsset.id
|
||||
? { ...asset, progress }
|
||||
: asset
|
||||
));
|
||||
};
|
||||
|
||||
try {
|
||||
updateProgress(10);
|
||||
const formData = new FormData();
|
||||
formData.append('assets', localAsset.file);
|
||||
|
||||
updateProgress(50);
|
||||
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, false);
|
||||
const uploadResult = uploadResponse.data;
|
||||
|
||||
if (uploadResult.uploaded_assets > 0) {
|
||||
// Update local asset status
|
||||
setLocalAssets(prev => prev.map(asset =>
|
||||
asset.id === localAsset.id
|
||||
? { ...asset, status: 'uploaded' }
|
||||
: asset
|
||||
));
|
||||
updateProgress(100);
|
||||
// Remove local asset from the list since it's now a backend asset
|
||||
setTimeout(() => {
|
||||
setLocalAssets(prev => prev.filter(asset => asset.id !== localAsset.id));
|
||||
}, 500); // Small delay to show 100% progress
|
||||
|
||||
// Fetch updated backend assets
|
||||
await fetchBackendAssets();
|
||||
|
|
@ -138,6 +239,7 @@ export default function AssetUploader({
|
|||
? {
|
||||
...asset,
|
||||
status: 'failed',
|
||||
progress: 0,
|
||||
error: (error as { response?: { data?: { error?: string } } }).response?.data?.error || 'Upload failed'
|
||||
}
|
||||
: asset
|
||||
|
|
@ -179,6 +281,72 @@ export default function AssetUploader({
|
|||
|
||||
setLocalAssets(prev => prev.filter(asset => asset.id !== assetId));
|
||||
};
|
||||
|
||||
// Handle drag and drop events
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Only set drag over to false if we're leaving the dropzone entirely
|
||||
if (!dropzoneRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all files functionality
|
||||
const handleClearAll = () => {
|
||||
if (localAssets.length === 0 && backendAssets.length === 0) return;
|
||||
|
||||
// Clean up preview URLs
|
||||
localAssets.forEach(asset => {
|
||||
if (asset.previewUrl) {
|
||||
URL.revokeObjectURL(asset.previewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
setLocalAssets([]);
|
||||
|
||||
// Remove backend assets if focusGroupId exists
|
||||
if (focusGroupId && backendAssets.length > 0) {
|
||||
Promise.all(backendAssets.map(asset =>
|
||||
focusGroupsApi.deleteAsset(focusGroupId, asset.filename).catch(console.error)
|
||||
)).then(() => {
|
||||
setBackendAssets([]);
|
||||
if (onAssetsChange) {
|
||||
onAssetsChange([]);
|
||||
}
|
||||
toast.info('All assets cleared');
|
||||
});
|
||||
} else {
|
||||
setBackendAssets([]);
|
||||
if (onAssetsChange) {
|
||||
onAssetsChange([]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetryUpload = async (localAsset: LocalAsset) => {
|
||||
if (!focusGroupId) return;
|
||||
|
|
@ -186,7 +354,7 @@ export default function AssetUploader({
|
|||
// Update status to uploading
|
||||
setLocalAssets(prev => prev.map(asset =>
|
||||
asset.id === localAsset.id
|
||||
? { ...asset, status: 'uploading', error: undefined }
|
||||
? { ...asset, status: 'uploading', error: undefined, progress: 0 }
|
||||
: asset
|
||||
));
|
||||
|
||||
|
|
@ -194,16 +362,28 @@ export default function AssetUploader({
|
|||
const formData = new FormData();
|
||||
formData.append('assets', localAsset.file);
|
||||
|
||||
// Update progress during retry
|
||||
setLocalAssets(prev => prev.map(asset =>
|
||||
asset.id === localAsset.id
|
||||
? { ...asset, progress: 50 }
|
||||
: asset
|
||||
));
|
||||
|
||||
const uploadResponse = await focusGroupsApi.uploadAssets(focusGroupId, formData, false);
|
||||
const uploadResult = uploadResponse.data;
|
||||
|
||||
if (uploadResult.uploaded_assets > 0) {
|
||||
setLocalAssets(prev => prev.map(asset =>
|
||||
asset.id === localAsset.id
|
||||
? { ...asset, status: 'uploaded' }
|
||||
? { ...asset, progress: 100 }
|
||||
: asset
|
||||
));
|
||||
|
||||
// Remove local asset from the list since it's now a backend asset
|
||||
setTimeout(() => {
|
||||
setLocalAssets(prev => prev.filter(asset => asset.id !== localAsset.id));
|
||||
}, 500);
|
||||
|
||||
await fetchBackendAssets();
|
||||
toast.success(`${localAsset.file.name} uploaded successfully`);
|
||||
} else {
|
||||
|
|
@ -215,6 +395,7 @@ export default function AssetUploader({
|
|||
? {
|
||||
...asset,
|
||||
status: 'failed',
|
||||
progress: 0,
|
||||
error: (error as { response?: { data?: { error?: string } } }).response?.data?.error || 'Upload failed'
|
||||
}
|
||||
: asset
|
||||
|
|
@ -283,79 +464,150 @@ export default function AssetUploader({
|
|||
const remainingSlots = maxAssets - totalAssets;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Upload area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center transition ${
|
||||
disabled
|
||||
? 'border-slate-100 bg-slate-25 cursor-not-allowed'
|
||||
: 'border-slate-200 bg-slate-50 hover:bg-slate-100 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{disabled ? (
|
||||
<>
|
||||
<UploadCloud className="h-10 w-10 text-slate-300 mb-2" />
|
||||
<p className="text-sm text-slate-400 mb-1">Asset Upload Disabled</p>
|
||||
<p className="text-xs text-slate-400 mb-3">Complete focus group details above to enable asset uploads</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
|
||||
<p className="text-sm text-slate-600 mb-1">{label}</p>
|
||||
<p className="text-xs text-slate-500 mb-3">{description}</p>
|
||||
<input
|
||||
type="file"
|
||||
accept={allowedTypes.join(',')}
|
||||
multiple
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
className="hidden"
|
||||
id="asset-uploader-input"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById('asset-uploader-input')?.click()}
|
||||
disabled={disabled || remainingSlots <= 0}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Select Files
|
||||
</Button>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
{remainingSlots} of {maxAssets} uploads remaining
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{/* Enhanced Upload Dropzone */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground">{label}</h3>
|
||||
<p className="text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={dropzoneRef}
|
||||
className={`relative border-2 border-dashed rounded-xl p-8 transition-all duration-200 ${
|
||||
disabled
|
||||
? 'border-muted-foreground/20 bg-muted/30 cursor-not-allowed'
|
||||
: isDragOver
|
||||
? 'border-primary bg-primary/5 scale-[1.02]'
|
||||
: 'border-muted-foreground/30 bg-background hover:border-primary/50 hover:bg-muted/50 cursor-pointer'
|
||||
}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<UploadCloud className={`h-12 w-12 mb-4 ${
|
||||
disabled ? 'text-muted-foreground/40' :
|
||||
isDragOver ? 'text-primary' : 'text-muted-foreground'
|
||||
}`} />
|
||||
|
||||
{disabled ? (
|
||||
<div>
|
||||
<p className="text-lg font-medium text-muted-foreground mb-2">File Upload Disabled</p>
|
||||
<p className="text-sm text-muted-foreground">Complete the required details above to enable file uploads</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-lg font-medium text-foreground mb-2">
|
||||
{isDragOver ? 'Drop files here' : 'Drag and drop files here, or click to browse'}
|
||||
</p>
|
||||
|
||||
{/* File Type Pills */}
|
||||
<div className="flex flex-wrap justify-center gap-2 mb-4">
|
||||
{getFileTypeNames().map((fileType, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{fileType}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Maximum file size: {maxFileSize}MB
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={allowedTypes.join(',')}
|
||||
multiple
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={disabled || remainingSlots <= 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Select Files
|
||||
</Button>
|
||||
|
||||
{(localAssets.length > 0 || backendAssets.length > 0) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearAll();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
{remainingSlots} of {maxAssets} uploads remaining
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assets preview */}
|
||||
{/* Enhanced Assets Preview */}
|
||||
{(backendAssets.length > 0 || localAssets.length > 0) && (
|
||||
<Card className="p-4">
|
||||
<h4 className="text-sm font-medium mb-3">
|
||||
Uploaded Assets ({backendAssets.length + localAssets.filter(a => a.status === 'uploaded').length})
|
||||
</h4>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-lg font-semibold text-foreground">
|
||||
Uploaded Files ({localAssets.length + backendAssets.length})
|
||||
</h4>
|
||||
{(localAssets.length > 0 || backendAssets.length > 0) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Backend assets (successfully uploaded) */}
|
||||
{backendAssets.map((asset, index) => (
|
||||
<div key={asset.filename} className="flex items-center gap-4 p-3 border rounded-lg bg-white">
|
||||
{/* Asset preview */}
|
||||
<div className="w-12 h-12 bg-slate-100 rounded flex items-center justify-center flex-shrink-0">
|
||||
<div key={asset.filename} className="flex items-center gap-4 p-4 border rounded-lg bg-card shadow-sm">
|
||||
{/* File preview/icon */}
|
||||
<div className="w-12 h-12 bg-muted/50 rounded-md flex items-center justify-center flex-shrink-0">
|
||||
{asset.mime_type?.startsWith('image/') ? (
|
||||
<img
|
||||
src={focusGroupsApi.getAssetUrl(focusGroupId!, asset.filename)}
|
||||
alt={getDisplayName(asset, index)}
|
||||
className="max-h-full max-w-full object-contain rounded"
|
||||
className="max-h-full max-w-full object-contain rounded-md"
|
||||
/>
|
||||
) : (
|
||||
getAssetIcon(asset.mime_type)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Asset info and naming */}
|
||||
{/* File info */}
|
||||
<div className="flex-grow min-w-0">
|
||||
{editingAsset === asset.filename ? (
|
||||
{enableRenaming && editingAsset === asset.filename ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={editingName}
|
||||
|
|
@ -365,7 +617,7 @@ export default function AssetUploader({
|
|||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault(); // Prevent form submission
|
||||
e.preventDefault();
|
||||
saveAssetName(asset.filename);
|
||||
} else if (e.key === 'Escape') {
|
||||
cancelEditingAssetName();
|
||||
|
|
@ -395,37 +647,46 @@ export default function AssetUploader({
|
|||
<p className="font-medium text-sm truncate">
|
||||
{getDisplayName(asset, index)}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => startEditingAssetName(asset)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
{enableRenaming && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => startEditingAssetName(asset)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
Original: {asset.original_name}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs text-green-600 bg-green-50 border-green-200">
|
||||
Complete
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 truncate">
|
||||
Original: {asset.original_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions and status */}
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-slate-500 mb-1">Will appear as:</div>
|
||||
<div className="text-sm font-medium text-primary">
|
||||
"{getDisplayName(asset, index)}"
|
||||
{enableRenaming && (
|
||||
<div className="text-right mr-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">Will appear as:</div>
|
||||
<div className="text-sm font-medium text-primary">
|
||||
"{getDisplayName(asset, index)}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => handleRemoveAsset(asset.filename)}
|
||||
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -433,43 +694,68 @@ export default function AssetUploader({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{/* Local assets (uploading/failed) */}
|
||||
{/* Local assets (uploading/failed/completed) */}
|
||||
{localAssets.map((asset) => (
|
||||
<div key={asset.id} className="flex items-center gap-4 p-3 border rounded-lg bg-slate-50">
|
||||
{/* Asset preview */}
|
||||
<div className="w-12 h-12 bg-slate-100 rounded flex items-center justify-center flex-shrink-0">
|
||||
<div key={asset.id} className="flex items-center gap-4 p-4 border rounded-lg bg-muted/30">
|
||||
{/* File preview/icon */}
|
||||
<div className="w-12 h-12 bg-muted/50 rounded-md flex items-center justify-center flex-shrink-0">
|
||||
{asset.previewUrl ? (
|
||||
<img
|
||||
src={asset.previewUrl}
|
||||
alt={asset.file.name}
|
||||
className="max-h-full max-w-full object-contain rounded"
|
||||
className="max-h-full max-w-full object-contain rounded-md"
|
||||
/>
|
||||
) : (
|
||||
getAssetIcon(asset.file.type)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Asset info */}
|
||||
{/* File info */}
|
||||
<div className="flex-grow min-w-0">
|
||||
<p className="font-medium text-sm truncate">{asset.file.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{(asset.file.size / 1024).toFixed(1)} KB
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{(asset.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
|
||||
{/* Progress bar for uploading files */}
|
||||
{asset.status === 'uploading' && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={asset.progress || 0} className="h-2" />
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>Uploading...</span>
|
||||
<span>{asset.progress || 0}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message for failed files */}
|
||||
{asset.error && (
|
||||
<p className="text-xs text-red-500 truncate">{asset.error}</p>
|
||||
<p className="text-xs text-destructive truncate mt-1">{asset.error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status and actions */}
|
||||
{/* Status badge and actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Status badge */}
|
||||
{asset.status === 'uploading' && (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-xs">Uploading...</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs text-blue-600 bg-blue-50 border-blue-200">
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
Uploading
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{asset.status === 'uploaded' && (
|
||||
<Badge variant="outline" className="text-xs text-green-600 bg-green-50 border-green-200">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
{asset.status === 'failed' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs text-destructive bg-destructive/10 border-destructive/20">
|
||||
Failed
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -482,12 +768,14 @@ export default function AssetUploader({
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remove button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => handleRemoveLocalAsset(asset.id)}
|
||||
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -497,9 +785,9 @@ export default function AssetUploader({
|
|||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
{backendAssets.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">
|
||||
{enableRenaming && backendAssets.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-primary/5 border border-primary/20 rounded-lg">
|
||||
<p className="text-sm text-foreground">
|
||||
<strong>Asset Names:</strong> Click the edit icon to customize how assets will be referenced
|
||||
in the discussion guide. Leave blank to use default numbering.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1545,9 +1545,11 @@ Controls how much time GPT-5 spends thinking before responding
|
|||
setBackendAssets(assets);
|
||||
}}
|
||||
maxAssets={10}
|
||||
allowedTypes={['image/*', 'application/pdf', 'video/*']}
|
||||
label="Upload Creative Assets"
|
||||
description="Upload images, mockups, or product designs for testing"
|
||||
maxFileSize={10}
|
||||
allowedTypes={['image/*', 'application/pdf', 'video/*', 'text/*', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']}
|
||||
label="Asset Upload"
|
||||
description="Provide any files you wish the moderator to use in the focus group session. This could include mockups, designs, documents, or other materials for discussion."
|
||||
enableRenaming={true}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Upload visuals that you want feedback on during the session
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { z } from "zod";
|
|||
import { Upload, Users, FileText, RefreshCw, CheckCircle2, UploadCloud, Lightbulb, X, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { aiPersonasApi } from '@/lib/api';
|
||||
import AssetUploader from '@/components/AssetUploader';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -51,6 +52,15 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
|
|||
const [suggestions, setSuggestions] = useState<{audience_brief: string[], research_objective: string[]}>({audience_brief: [], research_objective: []});
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
|
||||
const [enhancementError, setEnhancementError] = useState<string | null>(null);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<FileList | null>(null);
|
||||
const [currentFiles, setCurrentFiles] = useState<File[]>([]);
|
||||
|
||||
// Helper function to convert File array to FileList
|
||||
const createFileList = (files: File[]): FileList => {
|
||||
const dt = new DataTransfer();
|
||||
files.forEach(file => dt.items.add(file));
|
||||
return dt.files;
|
||||
};
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
|
|
@ -212,54 +222,25 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
|
|||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dataFile"
|
||||
render={({ field: { value, onChange, ...fieldProps } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Customer Data (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<div className="border-2 border-dashed border-slate-200 rounded-lg p-6 flex flex-col items-center justify-center bg-slate-50 hover:bg-slate-100 transition cursor-pointer">
|
||||
<UploadCloud className="h-10 w-10 text-slate-400 mb-2" />
|
||||
<p className="text-sm text-slate-600 mb-1">Upload customer data for more accurate personas</p>
|
||||
<p className="text-xs text-slate-500 mb-3">Supports PDF, Office docs, images, and more</p>
|
||||
<Input
|
||||
{...fieldProps}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.docx,.pptx,.xlsx,.html,.xml,.rtf,.pages,.key,.epub,.txt,.csv,.jpg,.jpeg,.png"
|
||||
onChange={(e) => {
|
||||
onChange(e.target.files);
|
||||
}}
|
||||
className="hidden"
|
||||
id="data-file-input"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById('data-file-input')?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Select Files
|
||||
</Button>
|
||||
{value && value.length > 0 && (
|
||||
<p className="text-xs text-primary mt-2">
|
||||
{value.length === 1
|
||||
? value[0].name
|
||||
: `${value.length} files selected`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Upload existing customer data to create more realistic personas
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<AssetUploader
|
||||
focusGroupId={undefined} // No backend upload for persona creation
|
||||
disabled={isGenerating}
|
||||
maxAssets={5}
|
||||
maxFileSize={10}
|
||||
allowedTypes={['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/*', 'image/*']}
|
||||
label="Customer Data Upload"
|
||||
description="Upload existing customer data to create more realistic and accurate synthetic personas. This helps the AI understand your target audience better."
|
||||
enableRenaming={false}
|
||||
onFilesChange={(files) => {
|
||||
setCurrentFiles(files);
|
||||
setUploadedFiles(files.length > 0 ? createFileList(files) : null);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supports PDF, Word docs, Excel files, text files, and images
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 p-4 rounded-lg border border-border">
|
||||
<div className="flex items-center mb-2">
|
||||
|
|
@ -431,9 +412,17 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
|
|||
|
||||
<div className="flex flex-col items-end">
|
||||
<Button
|
||||
type="submit"
|
||||
type="button"
|
||||
disabled={isGenerating}
|
||||
className="min-w-36"
|
||||
onClick={() => {
|
||||
const formData = form.getValues();
|
||||
// Replace the dataFile with uploadedFiles from AssetUploader
|
||||
onSubmit({
|
||||
...formData,
|
||||
dataFile: uploadedFiles
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -57,8 +57,11 @@ export default function PersonaProfile() {
|
|||
}
|
||||
}, [navigationState.focusGroupId, navigationState.previousRoute]);
|
||||
|
||||
// Determine if we should show breadcrumbs
|
||||
const showBreadcrumbs = navigationState.previousRoute?.startsWith('/focus-groups/') && navigationState.focusGroupId;
|
||||
// Determine if we should show breadcrumbs - only if we have both a focus group route and ID
|
||||
// This ensures we don't show stale breadcrumb data from previous sessions
|
||||
const showBreadcrumbs = navigationState.previousRoute?.startsWith('/focus-groups/') &&
|
||||
navigationState.focusGroupId &&
|
||||
Object.keys(navigationState).length > 0;
|
||||
|
||||
// Handle persona profile export
|
||||
const handleExportProfile = async () => {
|
||||
|
|
|
|||
|
|
@ -627,6 +627,8 @@ export function usePersonaDetails() {
|
|||
if (navigationState.previousRoute && navigationState.previousRoute.startsWith('/focus-groups/') && navigationState.focusGroupId) {
|
||||
// Navigate back to the focus group session
|
||||
navigate(`/focus-groups/${navigationState.focusGroupId}`);
|
||||
// Clear navigation state after using it
|
||||
clearNavigationState();
|
||||
}
|
||||
// Check if we came from focus group editing
|
||||
else if (navigationState.previousRoute === '/focus-groups' && navigationState.focusGroupTab) {
|
||||
|
|
@ -641,11 +643,13 @@ export function usePersonaDetails() {
|
|||
// Fallback to focus groups page in create mode with participants tab
|
||||
navigate('/focus-groups?mode=create&tab=participants');
|
||||
}
|
||||
// Clear navigation state after using it
|
||||
clearNavigationState();
|
||||
} else if (isFromReview) {
|
||||
// Legacy behavior for review mode
|
||||
navigate('/synthetic-users?mode=create&tab=ai&step=review');
|
||||
} else {
|
||||
// Default behavior - go to synthetic users page
|
||||
// Default behavior - go to synthetic users page (coming from synthetic users list)
|
||||
navigate('/synthetic-users');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -563,7 +563,7 @@ const FocusGroupSession = () => {
|
|||
id: msg._id || msg.id || `msg-${Date.now()}`,
|
||||
senderId: msg.senderId,
|
||||
text: msg.text,
|
||||
timestamp: new Date(msg.timestamp || msg.created_at || new Date()),
|
||||
timestamp: new Date(msg.timestamp || msg.created_at),
|
||||
type: msg.type || 'response',
|
||||
highlighted: msg.highlighted || false,
|
||||
visualAsset: msg.visualAsset // Include visual asset metadata for image display
|
||||
|
|
@ -583,9 +583,9 @@ const FocusGroupSession = () => {
|
|||
id: event._id || event.id || `event-${Date.now()}`,
|
||||
focus_group_id: event.focus_group_id,
|
||||
event_type: event.event_type,
|
||||
timestamp: new Date(event.timestamp || event.created_at || new Date()),
|
||||
timestamp: new Date(event.timestamp || event.created_at),
|
||||
user_id: event.user_id,
|
||||
created_at: new Date(event.created_at || new Date())
|
||||
created_at: new Date(event.created_at)
|
||||
}));
|
||||
|
||||
// Always update mode events
|
||||
|
|
@ -1106,30 +1106,13 @@ const FocusGroupSession = () => {
|
|||
try {
|
||||
const firstDiscussionItem = getFirstDiscussionItem(focusGroup?.discussionGuide);
|
||||
|
||||
// Create the initial moderator message
|
||||
const initialMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
senderId: 'moderator',
|
||||
text: firstDiscussionItem.content,
|
||||
timestamp: new Date(),
|
||||
type: 'question'
|
||||
};
|
||||
|
||||
// Send the message to the API
|
||||
// Send the message to the API - it will be added back via websocket/polling with server timestamp
|
||||
const msgResponse = await focusGroupsApi.sendMessage(id, {
|
||||
senderId: 'moderator',
|
||||
text: initialMessage.text,
|
||||
text: firstDiscussionItem.content,
|
||||
type: 'question'
|
||||
});
|
||||
|
||||
// Update message ID if provided by API
|
||||
if (msgResponse?.data?.message_id) {
|
||||
initialMessage.id = msgResponse.data.message_id;
|
||||
}
|
||||
|
||||
// Add the message to the local state using the existing handler
|
||||
handleNewMessage(initialMessage);
|
||||
|
||||
console.log('🚀 Initial moderator message created:', {
|
||||
content: firstDiscussionItem.content,
|
||||
sectionId: firstDiscussionItem.sectionId,
|
||||
|
|
@ -1160,30 +1143,13 @@ const FocusGroupSession = () => {
|
|||
try {
|
||||
if (!id) return;
|
||||
|
||||
// Create a generic moderator message
|
||||
const newMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
senderId: 'moderator',
|
||||
text: 'Can you share specific examples that you think handle this particularly well?',
|
||||
timestamp: new Date(),
|
||||
type: 'question'
|
||||
};
|
||||
|
||||
// Send to API
|
||||
// Send to API - message will be added back via websocket/polling with server timestamp
|
||||
const msgResponse = await focusGroupsApi.sendMessage(id, {
|
||||
senderId: 'moderator',
|
||||
text: newMessage.text,
|
||||
text: 'Can you share specific examples that you think handle this particularly well?',
|
||||
type: 'question'
|
||||
});
|
||||
|
||||
// Update message ID if provided by API
|
||||
if (msgResponse?.data?.message_id) {
|
||||
newMessage.id = msgResponse.data.message_id;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setMessages(prev => [...prev, newMessage]);
|
||||
|
||||
toastService.info("Added moderator message", {
|
||||
description: "You can now click 'Advance Discussion' to get AI-generated responses."
|
||||
});
|
||||
|
|
@ -1659,10 +1625,25 @@ const FocusGroupSession = () => {
|
|||
|
||||
const getMessageTimestamp = (): Date => {
|
||||
const messageId = getMostRecentMessageId();
|
||||
if (!messageId || messages.length === 0) return new Date();
|
||||
|
||||
const associatedMessage = messages.find(msg => msg.id === messageId);
|
||||
return associatedMessage ? associatedMessage.timestamp : new Date();
|
||||
// If we have messages, use the most recent message timestamp
|
||||
if (messageId && messages.length > 0) {
|
||||
const associatedMessage = messages.find(msg => msg.id === messageId);
|
||||
if (associatedMessage) {
|
||||
return associatedMessage.timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use focus group date, session start time, or current time as last resort
|
||||
if (focusGroup?.date) {
|
||||
return new Date(focusGroup.date);
|
||||
}
|
||||
|
||||
if (sessionStartTime) {
|
||||
return sessionStartTime;
|
||||
}
|
||||
|
||||
return new Date();
|
||||
};
|
||||
|
||||
// Theme generation functions
|
||||
|
|
@ -2206,20 +2187,7 @@ const FocusGroupSession = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Create and send the moderator message
|
||||
const moderatorMessage: Message = {
|
||||
id: `msg-${Date.now()}`,
|
||||
senderId: 'moderator',
|
||||
text: enhancedMessageText, // Use enhanced text
|
||||
timestamp: new Date(),
|
||||
type: 'question',
|
||||
visualAsset: hasImageAttached && visualAsset ? {
|
||||
filename: assetFilename,
|
||||
displayReference: visualAsset.display_reference
|
||||
} : undefined
|
||||
};
|
||||
|
||||
// Send to API first with visual asset information
|
||||
// Send the moderator message to API - it will be added back via websocket/polling with server timestamp
|
||||
console.log('📤 Sending moderator message to API:', {
|
||||
text: enhancedMessageText,
|
||||
attachedAssets,
|
||||
|
|
@ -2229,7 +2197,7 @@ const FocusGroupSession = () => {
|
|||
try {
|
||||
const msgResponse = await focusGroupsApi.sendMessage(id, {
|
||||
senderId: 'moderator',
|
||||
text: enhancedMessageText, // Use enhanced text in API call too
|
||||
text: enhancedMessageText,
|
||||
type: 'question',
|
||||
attached_assets: attachedAssets,
|
||||
activates_visual_context: activatesVisualContext,
|
||||
|
|
@ -2239,27 +2207,14 @@ const FocusGroupSession = () => {
|
|||
} : undefined
|
||||
});
|
||||
|
||||
if (msgResponse?.data?.message_id) {
|
||||
moderatorMessage.id = msgResponse.data.message_id;
|
||||
console.log('✅ Message API call successful, assigned ID:', moderatorMessage.id);
|
||||
} else {
|
||||
console.warn('⚠️ Message API call succeeded but no message_id returned:', msgResponse?.data);
|
||||
}
|
||||
console.log('✅ Message API call successful:', msgResponse?.data);
|
||||
} catch (msgError) {
|
||||
console.error('❌ Failed to save message to API:', msgError);
|
||||
toastService.warning('Message display only', {
|
||||
description: 'The moderator message is shown locally but may not be saved to the server.'
|
||||
toastService.warning('Message not saved', {
|
||||
description: 'Failed to save the moderator message to the server.'
|
||||
});
|
||||
}
|
||||
|
||||
// Add the message to the UI
|
||||
console.log('📨 Adding moderator message to UI:', {
|
||||
messageId: moderatorMessage.id,
|
||||
text: moderatorMessage.text,
|
||||
hasAssets: attachedAssets.length > 0
|
||||
});
|
||||
handleNewMessage(moderatorMessage);
|
||||
|
||||
// Close dialog first for immediate feedback
|
||||
setSetPositionDialog({ isOpen: false });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigation } from '@/contexts/NavigationContext';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import AIRecruiter from '@/components/AIRecruiter';
|
||||
import UserCreator from '@/components/UserCreator';
|
||||
|
|
@ -77,6 +78,7 @@ const SyntheticUsers = () => {
|
|||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { loadPersonas } = usePersonaStorage();
|
||||
const { clearNavigationState } = useNavigation();
|
||||
|
||||
const [mode, setMode] = useState<'view' | 'create'>('view');
|
||||
const [creationMode, setCreationMode] = useState<'manual' | 'ai'>('ai');
|
||||
|
|
@ -141,6 +143,14 @@ const SyntheticUsers = () => {
|
|||
setSummaryGenerationError(false);
|
||||
};
|
||||
|
||||
// Handle navigation to persona details from synthetic users list
|
||||
const handlePersonaClick = (persona: Persona) => {
|
||||
// Clear any existing navigation state since we're coming from synthetic users
|
||||
clearNavigationState();
|
||||
// Navigate to persona details
|
||||
navigate(`/synthetic-users/${persona._id || persona.id}`);
|
||||
};
|
||||
|
||||
// Function to collect unique filter options from personas
|
||||
const getFilterOptions = (personas: Persona[]) => {
|
||||
const options = {
|
||||
|
|
@ -800,10 +810,14 @@ const SyntheticUsers = () => {
|
|||
(activeFilters.folderStatus.includes('hasFolder') && activeFilters.folderStatus.includes('noFolder')) ||
|
||||
// If only "hasFolder" selected
|
||||
(activeFilters.folderStatus.includes('hasFolder') && !activeFilters.folderStatus.includes('noFolder') &&
|
||||
persona.folderId && persona.folderId !== DEFAULT_FOLDER_ID) ||
|
||||
((persona.folder_ids && persona.folder_ids.length > 0) ||
|
||||
(persona.folder_id && persona.folder_id !== DEFAULT_FOLDER_ID) ||
|
||||
(persona.folderId && persona.folderId !== DEFAULT_FOLDER_ID))) ||
|
||||
// If only "noFolder" selected
|
||||
(activeFilters.folderStatus.includes('noFolder') && !activeFilters.folderStatus.includes('hasFolder') &&
|
||||
(!persona.folderId || persona.folderId === DEFAULT_FOLDER_ID))
|
||||
((!persona.folder_ids || persona.folder_ids.length === 0) &&
|
||||
(!persona.folder_id || persona.folder_id === DEFAULT_FOLDER_ID) &&
|
||||
(!persona.folderId || persona.folderId === DEFAULT_FOLDER_ID)))
|
||||
);
|
||||
|
||||
// First check if the selected folder is "All Personas"
|
||||
|
|
@ -1397,7 +1411,7 @@ const SyntheticUsers = () => {
|
|||
<UserCard
|
||||
user={persona}
|
||||
selected={selectedPersonas.has(persona.id)}
|
||||
onClick={() => navigate(`/synthetic-users/${persona._id || persona.id}`)}
|
||||
onClick={() => handlePersonaClick(persona)}
|
||||
onSelectionToggle={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePersonaSelection(persona.id);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue