update site generation flow

This commit is contained in:
Krrish Ghimire
2026-07-12 00:10:15 +05:45
parent c362219a40
commit ec4d030c0a
15 changed files with 1285 additions and 22 deletions

View File

@@ -73,6 +73,40 @@ export interface GenerationVersion {
createdAt: string;
}
export interface PatternSuggestion {
title: string;
description: string;
}
export interface SectionSuggestion {
title: string;
description: string;
slug: string;
patterns: PatternSuggestion[];
}
export interface SuggestSectionsRequest {
briefDescription: string;
provider: string;
apiKey?: string;
baseUrl?: string;
model?: string;
}
export interface SuggestSectionsResponse {
sections: SectionSuggestion[];
}
export interface SelectedSection {
title: string;
description: string;
slug: string;
selected: boolean;
customNotes: string;
selectedPattern: string | null;
customPatternDesc: string;
}
export interface LLMKeyEntry {
configured: boolean;
baseUrl?: string;

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion } from '../models/llm';
import { LLMGenerateRequest, LLMRefineRequest, SiteStructure, GenerationVersion, SuggestSectionsRequest, SuggestSectionsResponse } from '../models/llm';
@Injectable({ providedIn: 'root' })
export class LlmApiService {
@@ -15,6 +15,10 @@ export class LlmApiService {
return this.http.post<SiteStructure>('/api/llm/refine', request);
}
suggestSections(request: SuggestSectionsRequest): Observable<SuggestSectionsResponse> {
return this.http.post<SuggestSectionsResponse>('/api/llm/suggest-sections', request);
}
getVersions(siteId: string): Observable<GenerationVersion[]> {
return this.http.get<GenerationVersion[]>(`/api/llm/versions/${siteId}`);
}

View File

@@ -20,10 +20,13 @@ import { LlmApiService } from '../core/services/llm-api.service';
import { LlmConfigService } from '../core/services/llm-config.service';
import { LlmSessionService } from './llm-session.service';
import { GenerationService } from './generation.service';
import { SiteStructure, GenerationVersion, LLMKeyStatus } from '../core/models/llm';
import { SiteStructure, GenerationVersion, LLMKeyStatus, SectionSuggestion } from '../core/models/llm';
import { SectionConfiguratorComponent, SectionConfig } from './section-configurator/section-configurator.component';
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
@Component({
selector: 'app-llm-workspace',
standalone: true,
@@ -33,6 +36,7 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
MatSnackBarModule, MatCardModule,
MatDividerModule, MatExpansionModule, MatDialogModule,
SectionConfiguratorComponent,
],
styles: [`
.provider-select { width: 140px; }
@@ -52,26 +56,24 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</button>
</mat-toolbar>
<div class="flex-1 overflow-y-auto">
<div class="px-6 py-6 space-y-6">
<mat-card class="p-4">
<mat-card class="p-4" *ngIf="currentPhase === 'idle'">
<mat-card-content class="!p-0">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Describe your website</mat-label>
<mat-label>Describe your website idea</mat-label>
<textarea
matInput
rows="5"
placeholder="e.g. A personal portfolio for a photographer with a gallery page, about section, and contact form. Dark theme with accent gold."
[(ngModel)]="prompt"
[disabled]="(session.generating$ | async) === true"
rows="4"
placeholder="e.g. A photography portfolio for Alex Chen"
[(ngModel)]="briefDescription"
[disabled]="suggesting"
></textarea>
</mat-form-field>
<div class="flex items-center justify-between mt-2">
<span class="text-xs text-gray-400">
Describe the pages, layout, theme, and content you want.
Briefly describe the type of site you want. The AI will suggest sections you can customize.
</span>
<div class="flex items-center gap-2">
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
@@ -84,19 +86,51 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
<button
mat-raised-button
color="primary"
[disabled]="!prompt.trim() || (session.generating$ | async) || configuredProviders.length === 0"
(click)="generate()"
[disabled]="!briefDescription.trim() || suggesting || configuredProviders.length === 0"
(click)="suggestSections()"
>
<mat-icon>auto_awesome</mat-icon>
Generate
Plan My Site
</button>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card *ngIf="configuredProviders.length === 0 && !(session.generating$ | async)" class="p-4" appearance="outlined">
<div *ngIf="suggesting" class="flex flex-col items-center justify-center py-12 space-y-4">
<mat-spinner diameter="48"></mat-spinner>
<div class="text-center">
<p class="text-lg font-medium text-gray-700">Analyzing your idea...</p>
<p class="text-sm text-gray-400">Finding the right sections for your site</p>
</div>
</div>
<!-- Phase 3: Section Configurator -->
<div *ngIf="currentPhase === 'configuring'" class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">
{{ configStepTitle }}
</h3>
<p class="text-sm text-gray-500">
{{ configStepSubtitle }}
</p>
</div>
<button mat-stroked-button (click)="backToIdle()">
<mat-icon>arrow_back</mat-icon>
Back
</button>
</div>
<app-section-configurator
[suggestions]="sectionSuggestions"
(configComplete)="onConfigComplete($event)"
(stepChange)="onConfigStepChange($event)"
></app-section-configurator>
</div>
<!-- API key setup (shown when no providers configured) -->
<mat-card *ngIf="configuredProviders.length === 0 && !(session.generating$ | async) && currentPhase !== 'generating'" class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">Configure AI Provider</h4>
<p class="text-sm text-gray-600 mb-3">
@@ -146,6 +180,15 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</div>
</div>
<div *ngIf="suggestError" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
<div class="flex-1">
<p class="text-sm font-medium text-red-800">Suggestion failed</p>
<p class="text-sm text-red-600 mt-1">{{ suggestError }}</p>
<button mat-button color="warn" class="mt-2" (click)="suggestError = null">Dismiss</button>
</div>
</div>
<div *ngIf="session.error$ | async as error" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<mat-icon class="text-red-500 mt-0.5">error_outline</mat-icon>
<div class="flex-1">
@@ -325,11 +368,11 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</div>
</div>
<div *ngIf="configuredProviders.length > 0 && !(session.generating$ | async) && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
<div *ngIf="configuredProviders.length > 0 && currentPhase === 'idle' && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
<mat-icon class="text-6xl mb-4" style="width: auto; height: auto; font-size: 4rem;">auto_awesome</mat-icon>
<h3 class="text-xl font-medium text-gray-500 mb-2">Describe your dream site</h3>
<p class="text-sm text-gray-400 text-center max-w-md">
Tell the AI what kind of website you want — pages, style, content — and it will generate a complete site structure ready for you to customize.
Tell the AI what kind of website you want. It will suggest pages and sections that you can customize before generating.
</p>
</div>
</div>
@@ -347,6 +390,17 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
private generationService = inject(GenerationService);
session = inject(LlmSessionService);
currentPhase: Phase = 'idle';
briefDescription = '';
sectionSuggestions: SectionSuggestion[] = [];
sectionConfig: SectionConfig | null = null;
configStepTitle = 'Customize Your Site';
configStepSubtitle = '';
suggestError: string | null = null;
suggesting = false;
prompt = '';
refinePrompt = '';
showRefine = false;
@@ -377,9 +431,14 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
const siteId = params.get('siteId');
if (siteId !== this.siteId) {
this.siteId = siteId;
this.briefDescription = '';
this.prompt = '';
this.refinePrompt = '';
this.showRefine = false;
this.currentPhase = 'idle';
this.sectionSuggestions = [];
this.sectionConfig = null;
this.suggestError = null;
this.session.clearHistory();
this.loadVersions();
this.loadLlmConfig();
@@ -439,17 +498,72 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
});
}
suggestSections(): void {
if (!this.briefDescription.trim() || !this.siteId || !this.selectedProvider) return;
this.suggesting = true;
this.suggestError = null;
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.api.suggestSections({
briefDescription: this.briefDescription,
provider: this.selectedProvider,
baseUrl,
model,
}).pipe(takeUntil(this.destroy$)).subscribe({
next: (response) => {
this.sectionSuggestions = response.sections;
this.suggesting = false;
this.currentPhase = 'configuring';
},
error: (err) => {
this.suggesting = false;
this.suggestError = err.error?.error || err.statusText || 'Failed to get section suggestions';
},
});
}
onConfigComplete(config: SectionConfig): void {
this.sectionConfig = config;
this.generate();
}
onConfigStepChange(step: string): void {
if (step.startsWith('section-')) {
const idx = parseInt(step.split('-')[1], 10);
const section = this.sectionSuggestions[idx];
this.configStepTitle = section ? `Configure ${section.title}` : 'Configure Sections';
this.configStepSubtitle = 'Choose a layout pattern or describe your own.';
} else if (step === 'theme') {
this.configStepTitle = 'Choose Theme Direction';
this.configStepSubtitle = 'Pick a general aesthetic for your site, or describe your own.';
} else if (step === 'details') {
this.configStepTitle = 'Additional Details';
this.configStepSubtitle = 'Any final preferences for the generated site.';
}
}
backToIdle(): void {
this.currentPhase = 'idle';
this.sectionSuggestions = [];
this.sectionConfig = null;
}
generate(): void {
if (!this.prompt.trim() || !this.siteId || !this.selectedProvider) return;
if (!this.sectionConfig || !this.siteId || !this.selectedProvider) return;
const compiledPrompt = this.buildPrompt();
this.prompt = compiledPrompt;
this.currentPhase = 'generating';
this.showRefine = false;
this.session.setPrompt(this.prompt);
this.session.setPrompt(compiledPrompt);
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.generationService.generate({
prompt: this.prompt,
prompt: compiledPrompt,
siteId: this.siteId,
provider: this.selectedProvider,
baseUrl,
@@ -457,6 +571,38 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
}, this.siteId);
}
private buildPrompt(): string {
const config = this.sectionConfig;
if (!config) return '';
const lines: string[] = [];
lines.push(`Site type: ${this.briefDescription}`);
if (config.themeVibe !== 'Any') {
lines.push(`Theme direction: ${config.themeVibe}`);
}
lines.push('');
lines.push('Pages:');
const selectedSections = config.sections.filter(s => s.selected);
for (const section of selectedSections) {
let desc = section.description;
if (section.customNotes.trim()) {
desc += ' ' + section.customNotes.trim();
}
lines.push(`- ${section.title}: ${desc}`);
}
if (config.additionalRequirements.trim()) {
lines.push('');
lines.push('Additional requirements:');
lines.push(config.additionalRequirements.trim());
}
return lines.join('\n');
}
refine(): void {
const result = this.session.currentResult;
if (!result || !this.refinePrompt.trim() || !this.siteId || !this.selectedProvider) return;

View File

@@ -0,0 +1,328 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { SectionSuggestion, SelectedSection, PatternSuggestion } from '../../core/models/llm';
export interface SectionConfig {
sections: SelectedSection[];
themeVibe: string;
additionalRequirements: string;
}
const THEME_VIBES = [
'Dark & Moody',
'Light & Airy',
'Bold & Vibrant',
'Minimal & Corporate',
'Playful & Fun',
'Luxe & Elegant',
'Earthy & Organic',
'Edgy & Urban',
];
@Component({
selector: 'app-section-configurator',
standalone: true,
imports: [
NgFor, NgIf, FormsModule,
MatButtonModule, MatIconModule, MatInputModule,
MatFormFieldModule, MatSelectModule,
],
styles: [`
.crumb { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8rem; font-weight: 500; padding: 0.25rem 0.75rem; border-radius: 9999px; transition: all 0.2s; }
.crumb.active { background: #3b82f6; color: #fff; }
.crumb.completed { color: #22c55e; }
.crumb.pending { color: #9ca3af; }
.crumb-line { flex: 1; height: 1.5px; background: #e5e7eb; min-width: 2rem; }
.crumb-line.completed { background: #22c55e; }
.pattern-option { display: block; width: 100%; text-align: left; border: 1.5px solid #e5e7eb; border-radius: 0.75rem; padding: 0.875rem 1rem; cursor: pointer; transition: all 0.15s; background: #fff; }
.pattern-option:hover { border-color: #93c5fd; background: #fafcff; }
.pattern-option.selected { border-color: #3b82f6; background: #eff6ff; }
`],
template: `
<div class="flex items-center mb-6">
<div class="crumb" [class.active]="phase === 'sections'" [class.completed]="phase !== 'sections'">Sections</div>
<div class="crumb-line" [class.completed]="phase !== 'sections'"></div>
<div class="crumb" [class.active]="phase === 'theme'" [class.completed]="phase === 'details'">Theme</div>
<div class="crumb-line" [class.completed]="phase === 'details'"></div>
<div class="crumb" [class.active]="phase === 'details'">Details</div>
</div>
<div *ngIf="phase === 'sections' && currentSection" class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h4 class="text-base font-semibold text-gray-900">Configure {{ currentSection.title }}</h4>
<p class="text-sm text-gray-500 mt-0.5">Section {{ sectionIndex + 1 }} of {{ sections.length }}</p>
</div>
</div>
<p class="text-sm text-gray-600">{{ currentSection.description }}</p>
<div *ngIf="!currentSection.selected" class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<mat-icon class="text-yellow-600 text-lg">warning_amber</mat-icon>
<span class="text-sm text-yellow-800">This section won't be included</span>
</div>
<button mat-stroked-button class="!text-xs !h-8 !px-3" (click)="currentSection.selected = true">
Include it
</button>
</div>
<div class="space-y-2.5">
<div
*ngFor="let pattern of currentSectionPatterns; let pi = index"
class="pattern-option"
[class.selected]="currentSection.selectedPattern === pattern.title"
(click)="selectPattern(pattern.title)"
>
<div class="flex items-start gap-3">
<div class="w-4 h-4 rounded-full border-2 mt-0.5 shrink-0 flex items-center justify-center"
[class.border-blue-500]="currentSection.selectedPattern === pattern.title"
[class.border-gray-300]="currentSection.selectedPattern !== pattern.title"
>
<div class="w-2 h-2 rounded-full" [class.bg-blue-500]="currentSection.selectedPattern === pattern.title"></div>
</div>
<div>
<div class="text-sm font-medium text-gray-900">{{ pattern.title }}</div>
<div class="text-xs text-gray-500 mt-0.5">{{ pattern.description }}</div>
</div>
</div>
</div>
<div
class="pattern-option"
[class.selected]="currentSection.selectedPattern === '__custom__'"
(click)="selectPattern('__custom__')"
>
<div class="flex items-start gap-3">
<div class="w-4 h-4 rounded-full border-2 mt-0.5 shrink-0 flex items-center justify-center"
[class.border-blue-500]="currentSection.selectedPattern === '__custom__'"
[class.border-gray-300]="currentSection.selectedPattern !== '__custom__'"
>
<div class="w-2 h-2 rounded-full" [class.bg-blue-500]="currentSection.selectedPattern === '__custom__'"></div>
</div>
<div class="flex-1">
<div class="text-sm font-medium text-gray-900">Custom</div>
<div class="text-xs text-gray-500 mt-0.5">Describe your own layout and design for this section.</div>
<div *ngIf="currentSection.selectedPattern === '__custom__'" class="mt-3">
<mat-form-field appearance="outline" class="w-full !mb-0">
<mat-label>Describe your custom layout</mat-label>
<textarea
matInput
rows="2"
placeholder="e.g. Full-width video background with a floating CTA and social links in the bottom corner"
[(ngModel)]="currentSection.customPatternDesc"
></textarea>
</mat-form-field>
</div>
</div>
</div>
</div>
</div>
<div *ngIf="currentSectionPatterns.length === 0" class="mt-3">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Custom notes for {{ currentSection.title }}</mat-label>
<textarea
matInput
rows="2"
placeholder="Describe what you want in this section..."
[(ngModel)]="currentSection.customNotes"
></textarea>
</mat-form-field>
</div>
<div class="mt-6 pt-4 border-t border-gray-100 text-center">
<button mat-button color="warn" (click)="skipSection()">
<mat-icon>remove_circle_outline</mat-icon>
Don't include this section
</button>
</div>
</div>
<div *ngIf="phase === 'theme'">
<h4 class="text-base font-semibold text-gray-900 mb-1">Choose Theme Direction</h4>
<p class="text-sm text-gray-500 mb-4">Pick a general aesthetic for your site, or describe your own.</p>
<mat-form-field appearance="outline" class="w-full sm:w-72">
<mat-label>Theme vibe</mat-label>
<mat-select [(ngModel)]="selectedVibe">
<mat-option value="Any">Any</mat-option>
<mat-option *ngFor="let vibe of themeVibes" [value]="vibe">{{ vibe }}</mat-option>
<mat-option value="__custom__">Custom...</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full" *ngIf="selectedVibe === '__custom__'">
<mat-label>Describe your custom theme</mat-label>
<input
matInput
placeholder="e.g. Cyberpunk neon with glitch effects"
[(ngModel)]="customVibeText"
/>
</mat-form-field>
</div>
<!-- Phase: Details -->
<div *ngIf="phase === 'details'">
<h4 class="text-base font-semibold text-gray-900 mb-1">Additional Details</h4>
<p class="text-sm text-gray-500 mb-4">Anything else you want the site to have — specific features, layout preferences, content ideas.</p>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Additional requirements (optional)</mat-label>
<textarea
matInput
rows="5"
placeholder="e.g. Include a newsletter signup form, add a sticky navigation bar, use a masonry grid layout for the gallery..."
[(ngModel)]="additionalRequirements"
></textarea>
</mat-form-field>
</div>
<div class="flex items-center justify-between mt-6 pt-4 border-t border-gray-200">
<button
mat-stroked-button
(click)="prev()"
[disabled]="phase === 'sections' && sectionIndex === 0"
>
<mat-icon>arrow_back</mat-icon>
Back
</button>
<div class="text-xs text-gray-400">
{{ navLabel }}
</div>
<button
mat-raised-button
color="primary"
(click)="next()"
>
{{ isLastStep ? 'Done' : 'Next' }}
<mat-icon *ngIf="!isLastStep">arrow_forward</mat-icon>
<mat-icon *ngIf="isLastStep">check</mat-icon>
</button>
</div>
`,
})
export class SectionConfiguratorComponent implements OnInit {
@Input() suggestions: SectionSuggestion[] = [];
@Output() configComplete = new EventEmitter<SectionConfig>();
@Output() stepChange = new EventEmitter<string>();
phase: 'sections' | 'theme' | 'details' = 'sections';
sectionIndex = 0;
sections: SelectedSection[] = [];
themeVibes = THEME_VIBES;
selectedVibe = 'Any';
customVibeText = '';
additionalRequirements = '';
ngOnInit(): void {
this.sections = this.suggestions.map(s => ({
title: s.title,
description: s.description,
slug: s.slug,
selected: true,
customNotes: '',
selectedPattern: s.patterns?.length ? null : null,
customPatternDesc: '',
}));
if (this.currentSection && this.currentSectionPatterns.length > 0) {
this.currentSection.selectedPattern = this.currentSectionPatterns[0].title;
}
}
get currentSection(): SelectedSection | null {
return this.sections[this.sectionIndex] ?? null;
}
get currentSectionPatterns(): PatternSuggestion[] {
const sug = this.suggestions[this.sectionIndex];
return sug?.patterns ?? [];
}
selectPattern(title: string): void {
if (this.currentSection) {
this.currentSection.selectedPattern = title;
if (title !== '__custom__') {
this.currentSection.customPatternDesc = '';
}
}
}
get isLastStep(): boolean {
return this.phase === 'details';
}
get navLabel(): string {
if (this.phase === 'sections') {
return `Section ${this.sectionIndex + 1} of ${this.sections.length}`;
}
if (this.phase === 'theme') {
return 'Theme';
}
return 'Additional details';
}
skipSection(): void {
if (this.currentSection) {
this.currentSection.selected = false;
this.currentSection.selectedPattern = null;
}
if (this.sectionIndex < this.sections.length - 1) {
this.sectionIndex++;
this.stepChange.emit(`section-${this.sectionIndex}`);
} else {
this.phase = 'theme';
this.stepChange.emit('theme');
}
}
prev(): void {
if (this.phase === 'sections') {
if (this.sectionIndex > 0) {
this.sectionIndex--;
this.stepChange.emit(`section-${this.sectionIndex}`);
}
} else if (this.phase === 'theme') {
this.phase = 'sections';
this.sectionIndex = this.sections.length - 1;
this.stepChange.emit('sections');
} else if (this.phase === 'details') {
this.phase = 'theme';
this.stepChange.emit('theme');
}
}
next(): void {
if (this.phase === 'sections') {
if (this.sectionIndex < this.sections.length - 1) {
this.sectionIndex++;
this.stepChange.emit(`section-${this.sectionIndex}`);
} else {
this.phase = 'theme';
this.stepChange.emit('theme');
}
} else if (this.phase === 'theme') {
this.phase = 'details';
this.stepChange.emit('details');
} else if (this.phase === 'details') {
const vibe = this.selectedVibe === '__custom__'
? this.customVibeText.trim() || 'Any'
: this.selectedVibe;
this.configComplete.emit({
sections: this.sections,
themeVibe: vibe,
additionalRequirements: this.additionalRequirements,
});
}
}
}