Seth commited on
Commit ·
952e292
1
Parent(s): 7a74d14
update
Browse files
backend/app/main.py
CHANGED
|
@@ -103,25 +103,70 @@ async def save_prompts(request: PromptSaveRequest, db: Session = Depends(get_db)
|
|
| 103 |
raise HTTPException(status_code=500, detail=f"Error saving prompts: {str(e)}")
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
@app.get("/api/generate-sequences")
|
| 107 |
async def generate_sequences(
|
| 108 |
file_id: str = Query(...),
|
| 109 |
-
|
|
|
|
| 110 |
):
|
| 111 |
-
"""Generate email sequences using GPT with Server-Sent Events streaming
|
|
|
|
| 112 |
|
| 113 |
async def event_generator():
|
| 114 |
try:
|
| 115 |
-
# Get uploaded file
|
| 116 |
db_file = db.query(UploadedFile).filter(UploadedFile.file_id == file_id).first()
|
| 117 |
if not db_file:
|
| 118 |
yield f"data: {json.dumps({'type': 'error', 'error': 'File not found'})}\n\n"
|
| 119 |
return
|
| 120 |
|
| 121 |
-
# Read CSV
|
| 122 |
df = pd.read_csv(db_file.file_path)
|
| 123 |
-
|
| 124 |
-
# Get prompts for this file
|
| 125 |
prompts = db.query(Prompt).filter(Prompt.file_id == file_id).all()
|
| 126 |
prompt_dict = {p.product_name: p.prompt_template for p in prompts}
|
| 127 |
|
|
@@ -129,37 +174,59 @@ async def generate_sequences(
|
|
| 129 |
yield f"data: {json.dumps({'type': 'error', 'error': 'No prompts found'})}\n\n"
|
| 130 |
return
|
| 131 |
|
| 132 |
-
# Get products from prompts
|
| 133 |
products = list(prompt_dict.keys())
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
# Clear existing sequences
|
| 136 |
-
db.query(GeneratedSequence).filter(GeneratedSequence.file_id == file_id).delete()
|
| 137 |
-
db.commit()
|
| 138 |
-
|
| 139 |
-
sequence_id = 1
|
| 140 |
total_contacts = len(df)
|
|
|
|
| 141 |
|
| 142 |
-
# Process each contact
|
| 143 |
for idx, row in df.iterrows():
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
-
|
| 148 |
product_name = products[sequence_id % len(products)]
|
| 149 |
prompt_template = prompt_dict[product_name]
|
| 150 |
|
| 151 |
-
# Generate sequence using GPT (run in executor to avoid blocking)
|
| 152 |
loop = asyncio.get_event_loop()
|
| 153 |
with concurrent.futures.ThreadPoolExecutor() as executor:
|
| 154 |
sequence_data_list = await loop.run_in_executor(
|
| 155 |
-
executor,
|
| 156 |
-
generate_email_sequence,
|
| 157 |
-
contact,
|
| 158 |
-
prompt_template,
|
| 159 |
-
product_name
|
| 160 |
)
|
| 161 |
|
| 162 |
-
# Save all emails for this contact to database
|
| 163 |
for seq_data in sequence_data_list:
|
| 164 |
db_sequence = GeneratedSequence(
|
| 165 |
file_id=file_id,
|
|
@@ -172,13 +239,12 @@ async def generate_sequences(
|
|
| 172 |
title=seq_data.get("title", ""),
|
| 173 |
product=seq_data["product"],
|
| 174 |
subject=seq_data["subject"],
|
| 175 |
-
email_content=seq_data["email_content"]
|
| 176 |
)
|
| 177 |
db.add(db_sequence)
|
| 178 |
|
| 179 |
db.commit()
|
| 180 |
|
| 181 |
-
# Stream all emails for this contact
|
| 182 |
for seq_data in sequence_data_list:
|
| 183 |
sequence_response = {
|
| 184 |
"id": sequence_id,
|
|
@@ -190,21 +256,15 @@ async def generate_sequences(
|
|
| 190 |
"title": seq_data.get("title", ""),
|
| 191 |
"product": seq_data["product"],
|
| 192 |
"subject": seq_data["subject"],
|
| 193 |
-
"emailContent": seq_data["email_content"]
|
| 194 |
}
|
| 195 |
-
|
| 196 |
yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
|
| 197 |
|
| 198 |
-
# Send progress update
|
| 199 |
progress = min(100, max(0, (sequence_id / total_contacts) * 100)) if total_contacts > 0 else 0
|
| 200 |
yield f"data: {json.dumps({'type': 'progress', 'progress': float(progress)})}\n\n"
|
| 201 |
-
|
| 202 |
sequence_id += 1
|
| 203 |
-
|
| 204 |
-
# Small delay to prevent overwhelming the API
|
| 205 |
await asyncio.sleep(0.1)
|
| 206 |
|
| 207 |
-
# Send completion
|
| 208 |
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
|
| 209 |
|
| 210 |
except Exception as e:
|
|
@@ -252,10 +312,10 @@ async def download_sequences(file_id: str = Query(...), db: Session = Depends(ge
|
|
| 252 |
output = io.StringIO()
|
| 253 |
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
|
| 254 |
|
| 255 |
-
# Write header
|
| 256 |
header = ['First Name', 'Last Name', 'Email', 'Company', 'Title', 'Product']
|
| 257 |
for i in range(1, max_email_number + 1):
|
| 258 |
-
header.extend([f'
|
| 259 |
writer.writerow(header)
|
| 260 |
|
| 261 |
# Write rows (one row per contact with subjects/bodies up to max_email_number)
|
|
@@ -287,12 +347,12 @@ async def download_sequences(file_id: str = Query(...), db: Session = Depends(ge
|
|
| 287 |
|
| 288 |
output.seek(0)
|
| 289 |
|
| 290 |
-
# Return as downloadable file
|
| 291 |
return StreamingResponse(
|
| 292 |
iter([output.getvalue()]),
|
| 293 |
media_type="text/csv",
|
| 294 |
headers={
|
| 295 |
-
"Content-Disposition":
|
| 296 |
}
|
| 297 |
)
|
| 298 |
|
|
|
|
| 103 |
raise HTTPException(status_code=500, detail=f"Error saving prompts: {str(e)}")
|
| 104 |
|
| 105 |
|
| 106 |
+
@app.get("/api/generation-status")
|
| 107 |
+
async def generation_status(file_id: str = Query(...), db: Session = Depends(get_db)):
|
| 108 |
+
"""Return progress for sequence generation (so frontend can resume after sleep/reconnect)."""
|
| 109 |
+
db_file = db.query(UploadedFile).filter(UploadedFile.file_id == file_id).first()
|
| 110 |
+
if not db_file:
|
| 111 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 112 |
+
total_contacts = db_file.contact_count or 0
|
| 113 |
+
completed = (
|
| 114 |
+
db.query(GeneratedSequence.sequence_id)
|
| 115 |
+
.filter(GeneratedSequence.file_id == file_id)
|
| 116 |
+
.distinct()
|
| 117 |
+
.count()
|
| 118 |
+
)
|
| 119 |
+
return {
|
| 120 |
+
"file_id": file_id,
|
| 121 |
+
"total_contacts": total_contacts,
|
| 122 |
+
"completed_count": completed,
|
| 123 |
+
"is_complete": total_contacts > 0 and completed >= total_contacts,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@app.get("/api/sequences")
|
| 128 |
+
async def get_sequences(file_id: str = Query(...), db: Session = Depends(get_db)):
|
| 129 |
+
"""Return all generated sequences for a file (for catch-up after reconnect)."""
|
| 130 |
+
sequences = (
|
| 131 |
+
db.query(GeneratedSequence)
|
| 132 |
+
.filter(GeneratedSequence.file_id == file_id)
|
| 133 |
+
.order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
|
| 134 |
+
.all()
|
| 135 |
+
)
|
| 136 |
+
out = []
|
| 137 |
+
for seq in sequences:
|
| 138 |
+
out.append({
|
| 139 |
+
"id": seq.sequence_id,
|
| 140 |
+
"emailNumber": seq.email_number,
|
| 141 |
+
"firstName": seq.first_name,
|
| 142 |
+
"lastName": seq.last_name,
|
| 143 |
+
"email": seq.email,
|
| 144 |
+
"company": seq.company,
|
| 145 |
+
"title": seq.title or "",
|
| 146 |
+
"product": seq.product,
|
| 147 |
+
"subject": seq.subject,
|
| 148 |
+
"emailContent": seq.email_content,
|
| 149 |
+
})
|
| 150 |
+
return {"sequences": out}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
@app.get("/api/generate-sequences")
|
| 154 |
async def generate_sequences(
|
| 155 |
file_id: str = Query(...),
|
| 156 |
+
reset: bool = Query(True),
|
| 157 |
+
db: Session = Depends(get_db),
|
| 158 |
):
|
| 159 |
+
"""Generate email sequences using GPT with Server-Sent Events streaming.
|
| 160 |
+
Use reset=1 for a fresh run (clears existing). Use reset=0 to resume after disconnect/sleep."""
|
| 161 |
|
| 162 |
async def event_generator():
|
| 163 |
try:
|
|
|
|
| 164 |
db_file = db.query(UploadedFile).filter(UploadedFile.file_id == file_id).first()
|
| 165 |
if not db_file:
|
| 166 |
yield f"data: {json.dumps({'type': 'error', 'error': 'File not found'})}\n\n"
|
| 167 |
return
|
| 168 |
|
|
|
|
| 169 |
df = pd.read_csv(db_file.file_path)
|
|
|
|
|
|
|
| 170 |
prompts = db.query(Prompt).filter(Prompt.file_id == file_id).all()
|
| 171 |
prompt_dict = {p.product_name: p.prompt_template for p in prompts}
|
| 172 |
|
|
|
|
| 174 |
yield f"data: {json.dumps({'type': 'error', 'error': 'No prompts found'})}\n\n"
|
| 175 |
return
|
| 176 |
|
|
|
|
| 177 |
products = list(prompt_dict.keys())
|
| 178 |
+
if reset:
|
| 179 |
+
db.query(GeneratedSequence).filter(GeneratedSequence.file_id == file_id).delete()
|
| 180 |
+
db.commit()
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
total_contacts = len(df)
|
| 183 |
+
sequence_id = 1
|
| 184 |
|
|
|
|
| 185 |
for idx, row in df.iterrows():
|
| 186 |
+
existing = (
|
| 187 |
+
db.query(GeneratedSequence)
|
| 188 |
+
.filter(
|
| 189 |
+
GeneratedSequence.file_id == file_id,
|
| 190 |
+
GeneratedSequence.sequence_id == sequence_id,
|
| 191 |
+
)
|
| 192 |
+
.order_by(GeneratedSequence.email_number)
|
| 193 |
+
.all()
|
| 194 |
+
)
|
| 195 |
+
if existing:
|
| 196 |
+
for seq in existing:
|
| 197 |
+
sequence_response = {
|
| 198 |
+
"id": seq.sequence_id,
|
| 199 |
+
"emailNumber": seq.email_number,
|
| 200 |
+
"firstName": seq.first_name,
|
| 201 |
+
"lastName": seq.last_name,
|
| 202 |
+
"email": seq.email,
|
| 203 |
+
"company": seq.company,
|
| 204 |
+
"title": seq.title or "",
|
| 205 |
+
"product": seq.product,
|
| 206 |
+
"subject": seq.subject,
|
| 207 |
+
"emailContent": seq.email_content,
|
| 208 |
+
}
|
| 209 |
+
yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
|
| 210 |
+
progress = min(100, max(0, (sequence_id / total_contacts) * 100)) if total_contacts > 0 else 0
|
| 211 |
+
yield f"data: {json.dumps({'type': 'progress', 'progress': float(progress)})}\n\n"
|
| 212 |
+
sequence_id += 1
|
| 213 |
+
await asyncio.sleep(0.05)
|
| 214 |
+
continue
|
| 215 |
|
| 216 |
+
contact = row.to_dict()
|
| 217 |
product_name = products[sequence_id % len(products)]
|
| 218 |
prompt_template = prompt_dict[product_name]
|
| 219 |
|
|
|
|
| 220 |
loop = asyncio.get_event_loop()
|
| 221 |
with concurrent.futures.ThreadPoolExecutor() as executor:
|
| 222 |
sequence_data_list = await loop.run_in_executor(
|
| 223 |
+
executor,
|
| 224 |
+
generate_email_sequence,
|
| 225 |
+
contact,
|
| 226 |
+
prompt_template,
|
| 227 |
+
product_name,
|
| 228 |
)
|
| 229 |
|
|
|
|
| 230 |
for seq_data in sequence_data_list:
|
| 231 |
db_sequence = GeneratedSequence(
|
| 232 |
file_id=file_id,
|
|
|
|
| 239 |
title=seq_data.get("title", ""),
|
| 240 |
product=seq_data["product"],
|
| 241 |
subject=seq_data["subject"],
|
| 242 |
+
email_content=seq_data["email_content"],
|
| 243 |
)
|
| 244 |
db.add(db_sequence)
|
| 245 |
|
| 246 |
db.commit()
|
| 247 |
|
|
|
|
| 248 |
for seq_data in sequence_data_list:
|
| 249 |
sequence_response = {
|
| 250 |
"id": sequence_id,
|
|
|
|
| 256 |
"title": seq_data.get("title", ""),
|
| 257 |
"product": seq_data["product"],
|
| 258 |
"subject": seq_data["subject"],
|
| 259 |
+
"emailContent": seq_data["email_content"],
|
| 260 |
}
|
|
|
|
| 261 |
yield f"data: {json.dumps({'type': 'sequence', 'sequence': sequence_response})}\n\n"
|
| 262 |
|
|
|
|
| 263 |
progress = min(100, max(0, (sequence_id / total_contacts) * 100)) if total_contacts > 0 else 0
|
| 264 |
yield f"data: {json.dumps({'type': 'progress', 'progress': float(progress)})}\n\n"
|
|
|
|
| 265 |
sequence_id += 1
|
|
|
|
|
|
|
| 266 |
await asyncio.sleep(0.1)
|
| 267 |
|
|
|
|
| 268 |
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
|
| 269 |
|
| 270 |
except Exception as e:
|
|
|
|
| 312 |
output = io.StringIO()
|
| 313 |
writer = csv.writer(output, quoting=csv.QUOTE_MINIMAL)
|
| 314 |
|
| 315 |
+
# Write header – fixed format: subject1, body1, subject2, body2, ... (separate columns, not merged)
|
| 316 |
header = ['First Name', 'Last Name', 'Email', 'Company', 'Title', 'Product']
|
| 317 |
for i in range(1, max_email_number + 1):
|
| 318 |
+
header.extend([f'subject{i}', f'body{i}'])
|
| 319 |
writer.writerow(header)
|
| 320 |
|
| 321 |
# Write rows (one row per contact with subjects/bodies up to max_email_number)
|
|
|
|
| 347 |
|
| 348 |
output.seek(0)
|
| 349 |
|
| 350 |
+
# Return as downloadable file (fixed format: one row per contact, separate subject/body columns)
|
| 351 |
return StreamingResponse(
|
| 352 |
iter([output.getvalue()]),
|
| 353 |
media_type="text/csv",
|
| 354 |
headers={
|
| 355 |
+
"Content-Disposition": "attachment; filename=email_sequences_fixed.csv"
|
| 356 |
}
|
| 357 |
)
|
| 358 |
|
frontend/src/components/sequences/SequenceViewer.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useEffect } from 'react';
|
| 2 |
import { Download, Mail, Loader2, CheckCircle2, Search, Filter } from 'lucide-react';
|
| 3 |
import { Button } from "@/components/ui/button";
|
| 4 |
import { Input } from "@/components/ui/input";
|
|
@@ -7,107 +7,170 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|
| 7 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 8 |
import SequenceCard from './SequenceCard';
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
const [sequences, setSequences] = useState([]);
|
| 12 |
const [contacts, setContacts] = useState([]);
|
| 13 |
const [progress, setProgress] = useState(0);
|
| 14 |
const [searchQuery, setSearchQuery] = useState('');
|
| 15 |
const [filterProduct, setFilterProduct] = useState('all');
|
| 16 |
const [isComplete, setIsComplete] = useState(false);
|
| 17 |
-
const [displayedCount, setDisplayedCount] = useState(50);
|
|
|
|
|
|
|
| 18 |
|
| 19 |
useEffect(() => {
|
| 20 |
-
if (isGenerating
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
setSequences([]);
|
| 22 |
setContacts([]);
|
| 23 |
setProgress(0);
|
| 24 |
setIsComplete(false);
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
});
|
| 55 |
-
updatedContacts = [...prev];
|
| 56 |
-
} else {
|
| 57 |
-
// Create new contact
|
| 58 |
-
updatedContacts = [...prev, {
|
| 59 |
-
id: sequence.id,
|
| 60 |
-
firstName: sequence.firstName,
|
| 61 |
-
lastName: sequence.lastName,
|
| 62 |
-
email: sequence.email,
|
| 63 |
-
company: sequence.company,
|
| 64 |
-
title: sequence.title,
|
| 65 |
-
product: sequence.product,
|
| 66 |
-
emails: [{
|
| 67 |
-
emailNumber: sequence.emailNumber || 1,
|
| 68 |
-
subject: sequence.subject,
|
| 69 |
-
emailContent: sequence.emailContent
|
| 70 |
-
}]
|
| 71 |
-
}];
|
| 72 |
}
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
setProgress(progressValue);
|
| 79 |
-
|
| 80 |
-
return updatedContacts;
|
| 81 |
});
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
} else if (data.type === 'error') {
|
| 89 |
-
console.error('Generation error:', data.error);
|
| 90 |
-
alert('Error generating sequences: ' + data.error);
|
| 91 |
-
eventSource.close();
|
| 92 |
}
|
| 93 |
-
} catch (error) {
|
| 94 |
-
console.error('Error parsing SSE data:', error);
|
| 95 |
}
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
}, [isGenerating, uploadedFile
|
| 111 |
|
| 112 |
const handleDownload = async () => {
|
| 113 |
try {
|
|
@@ -117,7 +180,7 @@ export default function SequenceViewer({ isGenerating, contactCount, selectedPro
|
|
| 117 |
const url = URL.createObjectURL(blob);
|
| 118 |
const a = document.createElement('a');
|
| 119 |
a.href = url;
|
| 120 |
-
a.download = '
|
| 121 |
a.click();
|
| 122 |
URL.revokeObjectURL(url);
|
| 123 |
} else {
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
import { Download, Mail, Loader2, CheckCircle2, Search, Filter } from 'lucide-react';
|
| 3 |
import { Button } from "@/components/ui/button";
|
| 4 |
import { Input } from "@/components/ui/input";
|
|
|
|
| 7 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 8 |
import SequenceCard from './SequenceCard';
|
| 9 |
|
| 10 |
+
function applySequenceToContacts(prev, sequence, contactCount, setProgress) {
|
| 11 |
+
const existingContact = prev.find(c =>
|
| 12 |
+
c.firstName === sequence.firstName &&
|
| 13 |
+
c.lastName === sequence.lastName &&
|
| 14 |
+
c.email === sequence.email
|
| 15 |
+
);
|
| 16 |
+
let updatedContacts;
|
| 17 |
+
if (existingContact) {
|
| 18 |
+
existingContact.emails.push({
|
| 19 |
+
emailNumber: sequence.emailNumber || existingContact.emails.length + 1,
|
| 20 |
+
subject: sequence.subject,
|
| 21 |
+
emailContent: sequence.emailContent
|
| 22 |
+
});
|
| 23 |
+
updatedContacts = [...prev];
|
| 24 |
+
} else {
|
| 25 |
+
updatedContacts = [...prev, {
|
| 26 |
+
id: sequence.id,
|
| 27 |
+
firstName: sequence.firstName,
|
| 28 |
+
lastName: sequence.lastName,
|
| 29 |
+
email: sequence.email,
|
| 30 |
+
company: sequence.company,
|
| 31 |
+
title: sequence.title,
|
| 32 |
+
product: sequence.product,
|
| 33 |
+
emails: [{
|
| 34 |
+
emailNumber: sequence.emailNumber || 1,
|
| 35 |
+
subject: sequence.subject,
|
| 36 |
+
emailContent: sequence.emailContent
|
| 37 |
+
}]
|
| 38 |
+
}];
|
| 39 |
+
}
|
| 40 |
+
const progressValue = contactCount > 0
|
| 41 |
+
? Math.min(100, Math.max(0, (updatedContacts.length / contactCount) * 100))
|
| 42 |
+
: 0;
|
| 43 |
+
setProgress(progressValue);
|
| 44 |
+
return updatedContacts;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export default function SequenceViewer({ isGenerating, generationRunId, contactCount, selectedProducts, uploadedFile, prompts, onComplete }) {
|
| 48 |
const [sequences, setSequences] = useState([]);
|
| 49 |
const [contacts, setContacts] = useState([]);
|
| 50 |
const [progress, setProgress] = useState(0);
|
| 51 |
const [searchQuery, setSearchQuery] = useState('');
|
| 52 |
const [filterProduct, setFilterProduct] = useState('all');
|
| 53 |
const [isComplete, setIsComplete] = useState(false);
|
| 54 |
+
const [displayedCount, setDisplayedCount] = useState(50);
|
| 55 |
+
const [reconnectKey, setReconnectKey] = useState(0);
|
| 56 |
+
const prevRunIdRef = useRef(null);
|
| 57 |
|
| 58 |
useEffect(() => {
|
| 59 |
+
if (!isGenerating || !uploadedFile?.fileId) return;
|
| 60 |
+
|
| 61 |
+
const isNewRun = prevRunIdRef.current !== generationRunId;
|
| 62 |
+
if (isNewRun) {
|
| 63 |
+
prevRunIdRef.current = generationRunId;
|
| 64 |
setSequences([]);
|
| 65 |
setContacts([]);
|
| 66 |
setProgress(0);
|
| 67 |
setIsComplete(false);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const reset = isNewRun ? 1 : 0;
|
| 71 |
+
const url = `/api/generate-sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}&reset=${reset}`;
|
| 72 |
+
const eventSource = new EventSource(url, { withCredentials: false });
|
| 73 |
+
|
| 74 |
+
eventSource.onmessage = (event) => {
|
| 75 |
+
try {
|
| 76 |
+
const data = JSON.parse(event.data);
|
| 77 |
+
if (data.type === 'sequence') {
|
| 78 |
+
const seq = data.sequence;
|
| 79 |
+
setSequences(prev => {
|
| 80 |
+
if (prev.some(s => s.id === seq.id && s.emailNumber === seq.emailNumber)) return prev;
|
| 81 |
+
return [...prev, seq];
|
| 82 |
+
});
|
| 83 |
+
setContacts(prev => {
|
| 84 |
+
const existing = prev.find(c => c.email === seq.email);
|
| 85 |
+
if (existing?.emails.some(e => e.emailNumber === seq.emailNumber)) return prev;
|
| 86 |
+
return applySequenceToContacts(prev, seq, contactCount, setProgress);
|
| 87 |
+
});
|
| 88 |
+
} else if (data.type === 'progress') {
|
| 89 |
+
setProgress(data.progress);
|
| 90 |
+
} else if (data.type === 'complete') {
|
| 91 |
+
setIsComplete(true);
|
| 92 |
+
onComplete?.();
|
| 93 |
+
eventSource.close();
|
| 94 |
+
} else if (data.type === 'error') {
|
| 95 |
+
console.error('Generation error:', data.error);
|
| 96 |
+
alert('Error generating sequences: ' + data.error);
|
| 97 |
+
eventSource.close();
|
| 98 |
+
}
|
| 99 |
+
} catch (err) {
|
| 100 |
+
console.error('Error parsing SSE data:', err);
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
eventSource.onerror = () => {
|
| 105 |
+
eventSource.close();
|
| 106 |
+
if (!isComplete) setReconnectKey(k => k + 1);
|
| 107 |
+
};
|
| 108 |
+
|
| 109 |
+
return () => eventSource.close();
|
| 110 |
+
}, [isGenerating, uploadedFile?.fileId, generationRunId, contactCount, reconnectKey, onComplete, isComplete]);
|
| 111 |
+
|
| 112 |
+
useEffect(() => {
|
| 113 |
+
if (!isGenerating || !uploadedFile?.fileId || reconnectKey === 0) return;
|
| 114 |
+
let cancelled = false;
|
| 115 |
+
(async () => {
|
| 116 |
+
try {
|
| 117 |
+
const [statusRes, seqRes] = await Promise.all([
|
| 118 |
+
fetch(`/api/generation-status?file_id=${encodeURIComponent(uploadedFile.fileId)}`),
|
| 119 |
+
fetch(`/api/sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}`)
|
| 120 |
+
]);
|
| 121 |
+
if (cancelled) return;
|
| 122 |
+
if (statusRes.ok && seqRes.ok) {
|
| 123 |
+
const status = await statusRes.json();
|
| 124 |
+
const { sequences: list } = await seqRes.json();
|
| 125 |
+
if (status.is_complete) {
|
| 126 |
+
setIsComplete(true);
|
| 127 |
+
onComplete?.();
|
| 128 |
+
}
|
| 129 |
+
if (list?.length > 0) {
|
| 130 |
+
const byContact = new Map();
|
| 131 |
+
list.forEach(seq => {
|
| 132 |
+
const key = seq.email;
|
| 133 |
+
if (!byContact.has(key)) {
|
| 134 |
+
byContact.set(key, {
|
| 135 |
+
id: seq.id,
|
| 136 |
+
firstName: seq.firstName,
|
| 137 |
+
lastName: seq.lastName,
|
| 138 |
+
email: seq.email,
|
| 139 |
+
company: seq.company,
|
| 140 |
+
title: seq.title,
|
| 141 |
+
product: seq.product,
|
| 142 |
+
emails: []
|
| 143 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
}
|
| 145 |
+
byContact.get(key).emails.push({
|
| 146 |
+
emailNumber: seq.emailNumber,
|
| 147 |
+
subject: seq.subject,
|
| 148 |
+
emailContent: seq.emailContent
|
| 149 |
+
});
|
|
|
|
|
|
|
|
|
|
| 150 |
});
|
| 151 |
+
const arr = [...byContact.values()];
|
| 152 |
+
arr.sort((a, b) => (a.id || 0) - (b.id || 0));
|
| 153 |
+
setSequences(list);
|
| 154 |
+
setContacts(arr);
|
| 155 |
+
const p = status.total_contacts > 0 ? Math.min(100, (arr.length / status.total_contacts) * 100) : 0;
|
| 156 |
+
setProgress(p);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
+
} catch (e) {
|
| 160 |
+
if (!cancelled) console.error('Reconnect fetch error:', e);
|
| 161 |
+
}
|
| 162 |
+
})();
|
| 163 |
+
return () => { cancelled = true; };
|
| 164 |
+
}, [reconnectKey, isGenerating, uploadedFile?.fileId, contactCount, onComplete]);
|
| 165 |
+
|
| 166 |
+
useEffect(() => {
|
| 167 |
+
if (!isGenerating || !uploadedFile?.fileId) return;
|
| 168 |
+
const onVisible = () => {
|
| 169 |
+
if (document.visibilityState === 'visible') setReconnectKey(k => k + 1);
|
| 170 |
+
};
|
| 171 |
+
document.addEventListener('visibilitychange', onVisible);
|
| 172 |
+
return () => document.removeEventListener('visibilitychange', onVisible);
|
| 173 |
+
}, [isGenerating, uploadedFile?.fileId]);
|
| 174 |
|
| 175 |
const handleDownload = async () => {
|
| 176 |
try {
|
|
|
|
| 180 |
const url = URL.createObjectURL(blob);
|
| 181 |
const a = document.createElement('a');
|
| 182 |
a.href = url;
|
| 183 |
+
a.download = 'email_sequences_fixed.csv';
|
| 184 |
a.click();
|
| 185 |
URL.revokeObjectURL(url);
|
| 186 |
} else {
|
frontend/src/components/smartlead/SmartleadPanel.jsx
CHANGED
|
@@ -85,7 +85,7 @@ export default function SmartleadPanel({ uploadedFile, sequencesCount, onPushCom
|
|
| 85 |
const url = URL.createObjectURL(blob);
|
| 86 |
const a = document.createElement('a');
|
| 87 |
a.href = url;
|
| 88 |
-
a.download = '
|
| 89 |
a.click();
|
| 90 |
URL.revokeObjectURL(url);
|
| 91 |
} else {
|
|
|
|
| 85 |
const url = URL.createObjectURL(blob);
|
| 86 |
const a = document.createElement('a');
|
| 87 |
a.href = url;
|
| 88 |
+
a.download = 'email_sequences_fixed.csv';
|
| 89 |
a.click();
|
| 90 |
URL.revokeObjectURL(url);
|
| 91 |
} else {
|
frontend/src/pages/EmailSequenceGenerator.jsx
CHANGED
|
@@ -15,6 +15,7 @@ export default function EmailSequenceGenerator() {
|
|
| 15 |
const [prompts, setPrompts] = useState({});
|
| 16 |
const [isGenerating, setIsGenerating] = useState(false);
|
| 17 |
const [generationComplete, setGenerationComplete] = useState(false);
|
|
|
|
| 18 |
const generateButtonRef = useRef(null);
|
| 19 |
|
| 20 |
const canProceedToStep2 = uploadedFile && selectedProducts.length > 0;
|
|
@@ -56,6 +57,7 @@ export default function EmailSequenceGenerator() {
|
|
| 56 |
return;
|
| 57 |
}
|
| 58 |
|
|
|
|
| 59 |
setStep(3);
|
| 60 |
setIsGenerating(true);
|
| 61 |
};
|
|
@@ -265,6 +267,7 @@ export default function EmailSequenceGenerator() {
|
|
| 265 |
|
| 266 |
<SequenceViewer
|
| 267 |
isGenerating={isGenerating}
|
|
|
|
| 268 |
contactCount={uploadedFile?.contactCount || 50}
|
| 269 |
selectedProducts={selectedProducts}
|
| 270 |
uploadedFile={uploadedFile}
|
|
|
|
| 15 |
const [prompts, setPrompts] = useState({});
|
| 16 |
const [isGenerating, setIsGenerating] = useState(false);
|
| 17 |
const [generationComplete, setGenerationComplete] = useState(false);
|
| 18 |
+
const [generationRunId, setGenerationRunId] = useState(0);
|
| 19 |
const generateButtonRef = useRef(null);
|
| 20 |
|
| 21 |
const canProceedToStep2 = uploadedFile && selectedProducts.length > 0;
|
|
|
|
| 57 |
return;
|
| 58 |
}
|
| 59 |
|
| 60 |
+
setGenerationRunId((r) => r + 1);
|
| 61 |
setStep(3);
|
| 62 |
setIsGenerating(true);
|
| 63 |
};
|
|
|
|
| 267 |
|
| 268 |
<SequenceViewer
|
| 269 |
isGenerating={isGenerating}
|
| 270 |
+
generationRunId={generationRunId}
|
| 271 |
contactCount={uploadedFile?.contactCount || 50}
|
| 272 |
selectedProducts={selectedProducts}
|
| 273 |
uploadedFile={uploadedFile}
|