cesjavi commited on
Commit
0797c65
·
1 Parent(s): 71b9493

Feat: AI Project Generation Wizard (Magic Generation) (Phase 9)

Browse files
backend/main.py CHANGED
@@ -118,10 +118,11 @@ async def root():
118
  }
119
 
120
  # Placeholder for routers
121
- from routers import agent_runner, orchestrator, monitoring
122
 
123
  app.include_router(agent_runner.router, prefix="/tasks", tags=["Tasks"])
124
- app.include_router(orchestrator.router, prefix="/orchestrator", tags=["Orchestration"])
 
125
  app.include_router(monitoring.router, prefix="/monitoring", tags=["Monitoring"])
126
 
127
  @app.get("/runtime-config.js", include_in_schema=False)
 
118
  }
119
 
120
  # Placeholder for routers
121
+ from routers import orchestrator, monitoring, agent_runner, generator
122
 
123
  app.include_router(agent_runner.router, prefix="/tasks", tags=["Tasks"])
124
+ app.include_router(orchestrator.router, prefix="/api/orchestrator", tags=["orchestrator"])
125
+ app.include_router(generator.router, prefix="/api/generator", tags=["generator"])
126
  app.include_router(monitoring.router, prefix="/monitoring", tags=["Monitoring"])
127
 
128
  @app.get("/runtime-config.js", include_in_schema=False)
backend/routers/generator.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, UploadFile, File, Form, HTTPException
2
+ from typing import List, Optional
3
+ import json
4
+ import logging
5
+ import groq
6
+ from services.supabase_service import supabase
7
+ from services.config import settings, config_service
8
+ from pydantic import BaseModel
9
+
10
+ router = APIRouter()
11
+ logger = logging.getLogger("aubm.generator")
12
+
13
+ def _parse_json_output(content: str):
14
+ """Robust JSON parsing from LLM output."""
15
+ if not content:
16
+ return {}
17
+ try:
18
+ return json.loads(content)
19
+ except json.JSONDecodeError:
20
+ pass
21
+ try:
22
+ if "```json" in content:
23
+ clean = content.split("```json", 1)[1].split("```", 1)[0].strip()
24
+ elif "```" in content:
25
+ clean = content.split("```", 1)[1].split("```", 1)[0].strip()
26
+ else:
27
+ object_start = content.find("{")
28
+ end = content.rfind("}")
29
+ clean = content[object_start:end + 1] if object_start != -1 and end != -1 else content
30
+ return json.loads(clean)
31
+ except Exception:
32
+ return {"name": "Generation Failed", "description": content, "context": ""}
33
+
34
+ @router.post("/generate-project")
35
+ async def generate_project(
36
+ prompt: str = Form(...),
37
+ files: List[UploadFile] = File(None)
38
+ ):
39
+ """
40
+ Generates a project structure from a natural language prompt and reference files.
41
+ """
42
+ logger.info("Generating project structure for prompt: %s", prompt[:50])
43
+
44
+ # 1. Extract context from files
45
+ file_contexts = []
46
+ if files:
47
+ for file in files:
48
+ content = await file.read()
49
+ try:
50
+ text = content.decode("utf-8")
51
+ file_contexts.append(f"File: {file.filename}\nContent:\n{text}")
52
+ except Exception as e:
53
+ logger.warning("Could not decode file %s: %s", file.filename, e)
54
+
55
+ full_context = "\n\n".join(file_contexts)
56
+
57
+ # 2. Prepare LLM prompt
58
+ system_prompt = """
59
+ You are an expert Project Architect for the Aubm platform.
60
+ Your goal is to take a user prompt and reference documents to create a structured project definition.
61
+
62
+ Return ONLY a valid JSON object with the following keys:
63
+ {
64
+ "name": "Short Professional Name",
65
+ "description": "High level summary",
66
+ "context": "Detailed constraints, objectives, and requirements extracted from docs.",
67
+ "sources": [{"kind": "note", "label": "Analysis Note", "content": "..."}]
68
+ }
69
+ """
70
+
71
+ user_message = f"User Prompt: {prompt}\n\nReference Context:\n{full_context}"
72
+
73
+ try:
74
+ # 3. Call Groq
75
+ provider_config = config_service.get_provider_config("groq")
76
+ api_key = provider_config.get("api_key") or settings.GROQ_API_KEY
77
+ client = groq.AsyncGroq(api_key=api_key)
78
+
79
+ response = await client.chat.completions.create(
80
+ model="llama-3.3-70b-versatile",
81
+ messages=[
82
+ {"role": "system", "content": system_prompt},
83
+ {"role": "user", "content": user_message}
84
+ ],
85
+ temperature=0.3,
86
+ max_tokens=2048,
87
+ response_format={"type": "json_object"}
88
+ )
89
+
90
+ response_text = response.choices[0].message.content
91
+ data = _parse_json_output(response_text)
92
+ return data
93
+
94
+ except Exception as e:
95
+ logger.error("Project generation failed: %s", e)
96
+ raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")
frontend/src/components/NewProject.tsx CHANGED
@@ -103,6 +103,10 @@ const wizardSteps = [
103
  {
104
  title: 'Review',
105
  description: 'Check the setup before creating the project. You will generate tasks from the project page after creation.'
 
 
 
 
106
  }
107
  ];
108
 
@@ -129,6 +133,9 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
129
  const [wizardStep, setWizardStep] = useState(0);
130
  const [saving, setSaving] = useState(false);
131
  const [message, setMessage] = useState<string | null>(null);
 
 
 
132
 
133
  React.useEffect(() => {
134
  if (uiMode === 'expert') {
@@ -136,6 +143,49 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
136
  }
137
  }, [uiMode]);
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  const fetchTeams = async () => {
140
  try {
141
  const { data, error } = await supabase.from('teams').select('id, name');
@@ -147,10 +197,10 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
147
  };
148
  const isWizard = true;
149
  const projectWizardSteps = uiMode === 'expert'
150
- ? [wizardSteps[0], wizardSteps[1], wizardSteps[2], expertAccessStep, wizardSteps[3]]
151
- : wizardSteps;
152
  const reviewStepIndex = projectWizardSteps.length - 1;
153
- const accessStepIndex = uiMode === 'expert' ? 3 : -1;
154
  const currentWizardStep = projectWizardSteps[wizardStep] ?? projectWizardSteps[0];
155
  const isFirstWizardStep = wizardStep === 0;
156
  const isLastWizardStep = wizardStep === reviewStepIndex;
@@ -312,7 +362,91 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
312
  </div>
313
  )}
314
 
315
- {(!isWizard || wizardStep === 0) && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  <>
317
  <div className="field-with-help">
318
  <label>
@@ -336,7 +470,7 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
336
  </>
337
  )}
338
 
339
- {(!isWizard || wizardStep === 1) && (
340
  <div className="field-with-help">
341
  <label>
342
  <span>Context</span>
@@ -348,7 +482,7 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
348
  </div>
349
  )}
350
 
351
- {(!isWizard || wizardStep === 2) && (
352
  <div className="default-agent-panel project-sources-panel" style={{ gap: 'var(--space-lg)' }}>
353
  <div className="settings-section-title">
354
  <Paperclip size={20} color="var(--accent)" />
@@ -445,7 +579,7 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
445
  </div>
446
  )}
447
 
448
- {uiMode === 'expert' && (!isWizard || wizardStep === accessStepIndex) && (
449
  <div className="expert-access-fields" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-lg)' }}>
450
  <div className="field-with-help">
451
  <label>
@@ -478,7 +612,7 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
478
  </div>
479
  )}
480
 
481
- {isWizard && wizardStep === reviewStepIndex && (
482
  <div className="wizard-review">
483
  <div>
484
  <span>Project name</span>
@@ -515,29 +649,6 @@ const NewProject: React.FC<{ uiMode: UiMode; onCreated?: () => void }> = ({ uiMo
515
  )}
516
 
517
  <div className="field-with-help field-with-help-action">
518
- {isWizard ? (
519
- <div className="wizard-actions">
520
- <button className="btn btn-glass" type="button" onClick={() => setWizardStep((step) => Math.max(0, step - 1))} disabled={isFirstWizardStep || saving}>
521
- <ArrowLeft size={18} />
522
- Back
523
- </button>
524
- {!isLastWizardStep ? (
525
- <button
526
- className="btn btn-primary"
527
- type="button"
528
- onClick={() => setWizardStep((step) => Math.min(projectWizardSteps.length - 1, step + 1))}
529
- disabled={wizardStep === 0 && !name.trim()}
530
- >
531
- Next
532
- <ArrowRight size={18} />
533
- </button>
534
- ) : (
535
- <button className="btn btn-primary" type="submit" disabled={saving || !name.trim()}>
536
- <PlusCircle size={18} />
537
- {saving ? 'Creating...' : 'Create Project'}
538
- </button>
539
- )}
540
- </div>
541
  ) : (
542
  <button className="btn btn-primary" type="submit" disabled={saving}>
543
  <PlusCircle size={18} />
 
103
  {
104
  title: 'Review',
105
  description: 'Check the setup before creating the project. You will generate tasks from the project page after creation.'
106
+ },
107
+ {
108
+ title: 'Magic Generation',
109
+ description: 'Describe your project in natural language and attach reference docs. AI will pre-configure the workspace for you.'
110
  }
111
  ];
112
 
 
133
  const [wizardStep, setWizardStep] = useState(0);
134
  const [saving, setSaving] = useState(false);
135
  const [message, setMessage] = useState<string | null>(null);
136
+ const [aiPrompt, setAiPrompt] = useState('');
137
+ const [isGenerating, setIsGenerating] = useState(false);
138
+ const [generationFiles, setGenerationFiles] = useState<File[]>([]);
139
 
140
  React.useEffect(() => {
141
  if (uiMode === 'expert') {
 
143
  }
144
  }, [uiMode]);
145
 
146
+ const handleAiGenerate = async () => {
147
+ if (!aiPrompt.trim()) return;
148
+ setIsGenerating(true);
149
+ setMessage('AI is analyzing your request and documents...');
150
+
151
+ try {
152
+ const formData = new FormData();
153
+ formData.append('prompt', aiPrompt);
154
+ generationFiles.forEach(file => {
155
+ formData.append('files', file);
156
+ });
157
+
158
+ const response = await fetch(`${getApiUrl()}/generator/generate-project`, {
159
+ method: 'POST',
160
+ body: formData
161
+ });
162
+
163
+ if (!response.ok) throw new Error('AI generation failed');
164
+
165
+ const data = await response.json();
166
+
167
+ setName(data.name || '');
168
+ setDescription(data.description || '');
169
+ setContext(data.context || '');
170
+
171
+ if (data.sources && Array.isArray(data.sources)) {
172
+ const aiSources: ProjectSource[] = data.sources.map((s: any) => ({
173
+ id: crypto.randomUUID(),
174
+ ...s
175
+ }));
176
+ setSources(prev => [...prev, ...aiSources]);
177
+ }
178
+
179
+ setMessage('Success! AI has drafted your project. Review the fields in the next steps.');
180
+ setWizardStep(1);
181
+ } catch (err: any) {
182
+ console.error('AI Generation Error:', err);
183
+ setMessage(`AI Error: ${err.message}`);
184
+ } finally {
185
+ setIsGenerating(false);
186
+ }
187
+ };
188
+
189
  const fetchTeams = async () => {
190
  try {
191
  const { data, error } = await supabase.from('teams').select('id, name');
 
197
  };
198
  const isWizard = true;
199
  const projectWizardSteps = uiMode === 'expert'
200
+ ? [wizardSteps[4], wizardSteps[0], wizardSteps[1], wizardSteps[2], expertAccessStep, wizardSteps[3]]
201
+ : [wizardSteps[4], wizardSteps[0], wizardSteps[1], wizardSteps[2], wizardSteps[3]];
202
  const reviewStepIndex = projectWizardSteps.length - 1;
203
+ const accessStepIndex = uiMode === 'expert' ? 4 : -1;
204
  const currentWizardStep = projectWizardSteps[wizardStep] ?? projectWizardSteps[0];
205
  const isFirstWizardStep = wizardStep === 0;
206
  const isLastWizardStep = wizardStep === reviewStepIndex;
 
362
  </div>
363
  )}
364
 
365
+ {wizardStep === 0 && (
366
+ <motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} className="wizard-form">
367
+ <div className="form-group">
368
+ <label>What would you like to build?</label>
369
+ <textarea
370
+ placeholder='e.g., "Make me a security audit project for a Fintech app. Use the attached compliance docs as reference. I need to focus on OWASP Top 10."'
371
+ value={aiPrompt}
372
+ onChange={(e) => setAiPrompt(e.target.value)}
373
+ style={{ height: '160px', resize: 'none' }}
374
+ />
375
+ </div>
376
+
377
+ <div className="form-group">
378
+ <label>Reference Documents (Optional)</label>
379
+ <div
380
+ className="drop-zone"
381
+ onDragOver={(e) => e.preventDefault()}
382
+ onDrop={(e) => {
383
+ e.preventDefault();
384
+ const dropped = Array.from(e.dataTransfer.files);
385
+ setGenerationFiles(prev => [...prev, ...dropped]);
386
+ }}
387
+ onClick={() => {
388
+ const input = document.createElement('input');
389
+ input.type = 'file';
390
+ input.multiple = true;
391
+ input.onchange = (e) => {
392
+ const selected = Array.from((e.target as HTMLInputElement).files || []);
393
+ setGenerationFiles(prev => [...prev, ...selected]);
394
+ };
395
+ input.click();
396
+ }}
397
+ style={{
398
+ border: '2px dashed var(--border)',
399
+ borderRadius: 'var(--radius-md)',
400
+ padding: 'var(--space-xl)',
401
+ textAlign: 'center',
402
+ cursor: 'pointer',
403
+ background: 'rgba(255,255,255,0.02)'
404
+ }}
405
+ >
406
+ <Paperclip size={24} style={{ marginBottom: '8px', opacity: 0.5 }} />
407
+ <p style={{ margin: 0 }}>Click or drag files to use as AI context</p>
408
+ <span style={{ fontSize: '0.8rem', opacity: 0.6 }}>Supports PDF, Text, Markdown, JSON</span>
409
+ </div>
410
+
411
+ {generationFiles.length > 0 && (
412
+ <div style={{ marginTop: 'var(--space-md)', display: 'flex', flexDirection: 'column', gap: '4px' }}>
413
+ {generationFiles.map((f, i) => (
414
+ <div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'rgba(255,255,255,0.04)', padding: '8px 12px', borderRadius: '4px' }}>
415
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.9rem' }}>
416
+ <FileText size={14} />
417
+ <span>{f.name}</span>
418
+ </div>
419
+ <button
420
+ className="btn-icon"
421
+ onClick={() => setGenerationFiles(prev => prev.filter((_, idx) => idx !== i))}
422
+ style={{ color: 'var(--danger)' }}
423
+ >
424
+ <Trash2 size={14} />
425
+ </button>
426
+ </div>
427
+ ))}
428
+ </div>
429
+ )}
430
+ </div>
431
+
432
+ <div style={{ marginTop: 'var(--space-xl)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
433
+ <button className="btn btn-glass" type="button" onClick={() => setWizardStep(1)}>
434
+ Skip to manual setup
435
+ </button>
436
+ <button
437
+ className="btn btn-primary"
438
+ type="button"
439
+ onClick={handleAiGenerate}
440
+ disabled={!aiPrompt.trim() || isGenerating}
441
+ >
442
+ {isGenerating ? <RefreshCw className="spin" size={18} /> : <PlusCircle size={18} />}
443
+ {isGenerating ? 'Generating...' : 'Generate Project Structure'}
444
+ </button>
445
+ </div>
446
+ </motion.div>
447
+ )}
448
+
449
+ {wizardStep === 1 && (
450
  <>
451
  <div className="field-with-help">
452
  <label>
 
470
  </>
471
  )}
472
 
473
+ {wizardStep === 2 && (
474
  <div className="field-with-help">
475
  <label>
476
  <span>Context</span>
 
482
  </div>
483
  )}
484
 
485
+ {wizardStep === 3 && (
486
  <div className="default-agent-panel project-sources-panel" style={{ gap: 'var(--space-lg)' }}>
487
  <div className="settings-section-title">
488
  <Paperclip size={20} color="var(--accent)" />
 
579
  </div>
580
  )}
581
 
582
+ {uiMode === 'expert' && wizardStep === accessStepIndex && (
583
  <div className="expert-access-fields" style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-lg)' }}>
584
  <div className="field-with-help">
585
  <label>
 
612
  </div>
613
  )}
614
 
615
+ {wizardStep === reviewStepIndex && (
616
  <div className="wizard-review">
617
  <div>
618
  <span>Project name</span>
 
649
  )}
650
 
651
  <div className="field-with-help field-with-help-action">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  ) : (
653
  <button className="btn btn-primary" type="submit" disabled={saving}>
654
  <PlusCircle size={18} />