generate site using llm prompt

This commit is contained in:
Krrish Ghimire
2026-07-02 01:15:16 +05:45
parent 18fed113f9
commit 3ccbd2ebec
75 changed files with 2220 additions and 3173 deletions

View File

@@ -0,0 +1,459 @@
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatCardModule } from '@angular/material/card';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LlmApiService } from '../core/services/llm-api.service';
import { LlmSessionService } from './llm-session.service';
import { SiteStructure, GenerationVersion } from '../core/models/llm';
import { getComponentLabel } from '../core/models/component';
import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component';
import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog';
@Component({
selector: 'app-llm-workspace',
standalone: true,
imports: [
NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule,
MatToolbarModule, MatButtonModule, MatIconModule,
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule,
MatSnackBarModule, MatCardModule,
MatDividerModule, MatExpansionModule, MatDialogModule,
],
template: `
<div class="h-screen flex flex-col bg-gray-50">
<mat-toolbar color="primary" class="h-14 flex items-center justify-between px-4">
<div class="flex items-center gap-3">
<button mat-icon-button (click)="goToDashboard()">
<mat-icon>arrow_back</mat-icon>
</button>
<span class="text-lg font-bold">AI Site Generator</span>
</div>
</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-content class="!p-0">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Describe your website</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"
></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.
</span>
<button
mat-raised-button
color="primary"
[disabled]="!prompt.trim() || (session.generating$ | async)"
(click)="generate()"
>
<mat-icon>auto_awesome</mat-icon>
Generate
</button>
</div>
</mat-card-content>
</mat-card>
<div *ngIf="session.generating$ | async" 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">Generating your site...</p>
<p class="text-sm text-gray-400">This usually takes 15-30 seconds</p>
</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">
<p class="text-sm font-medium text-red-800">Generation failed</p>
<p class="text-sm text-red-600 mt-1">{{ error }}</p>
<button mat-button color="warn" class="mt-2" (click)="retry()">Try Again</button>
</div>
</div>
<div *ngIf="session.currentResult$ | async as result" class="flex gap-6">
<div class="w-96 shrink-0">
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">History</h4>
<span class="text-xs text-gray-400">{{ versions.length }} version{{ versions.length !== 1 ? 's' : '' }}</span>
</div>
<div *ngIf="session.loadingVersions$ | async" class="flex justify-center py-4">
<mat-spinner diameter="20"></mat-spinner>
</div>
<div class="space-y-1 max-h-[calc(100vh-20rem)] overflow-y-auto overflow-x-hidden">
<div
*ngFor="let v of versions"
(click)="switchToVersion(v)"
class="w-full p-3 rounded-lg border transition-colors duration-150 flex flex-col gap-0.5 cursor-pointer group min-w-0"
[class.border-blue-500]="isCurrentVersion(v.id)"
[class.bg-blue-50]="isCurrentVersion(v.id)"
[class.border-gray-200]="!isCurrentVersion(v.id)"
[class.hover:bg-gray-50]="!isCurrentVersion(v.id)"
>
<div class="flex items-center gap-1.5 min-w-0">
<span class="text-xs font-bold text-gray-900 shrink-0">v{{ v.versionNumber }}</span>
<span class="text-[10px] text-gray-400 shrink-0">{{ v.createdAt | date:'short' }}</span>
<button
mat-icon-button
class="!w-6 !h-6 !min-w-0 !p-0 !leading-none opacity-40 hover:opacity-100 transition-opacity shrink-0"
(click)="$event.stopPropagation(); deleteVersion(v)"
title="Delete version"
>
<mat-icon class="!text-sm !w-4 !h-4 text-red-500">delete</mat-icon>
</button>
</div>
<p class="text-xs text-gray-600 truncate">{{ truncatePrompt(v.prompt, 50) }}</p>
</div>
<p *ngIf="versions.length === 0 && !(session.loadingVersions$ | async)" class="text-xs text-gray-400 py-2 text-center">
No versions yet
</p>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="flex-1 min-w-0 space-y-4">
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">Generated Site Structure</h3>
<p class="text-sm text-gray-500">
{{ result.pages.length }} page{{ result.pages.length !== 1 ? 's' : '' }}
&middot;
{{ totalComponents(result) }} component{{ totalComponents(result) !== 1 ? 's' : '' }}
</p>
</div>
<div class="flex gap-2">
<button mat-stroked-button color="primary" (click)="openPreview()">
<mat-icon>visibility</mat-icon>
Preview
</button>
<button mat-stroked-button color="primary" (click)="showRefine = !showRefine">
<mat-icon>refresh</mat-icon>
Refine
</button>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card *ngIf="currentVersionPrompt" class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">Prompt</h4>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{ currentVersionPrompt }}</p>
</mat-card-content>
</mat-card>
<mat-card *ngIf="showRefine" class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<mat-form-field appearance="outline" class="w-full">
<mat-label>Refinement instructions</mat-label>
<textarea
matInput
rows="3"
placeholder="e.g. Make it more minimal, change the primary color to teal, add a testimonials section..."
[(ngModel)]="refinePrompt"
[disabled]="(session.generating$ | async) === true"
></textarea>
</mat-form-field>
<div class="flex justify-end mt-2">
<button
mat-raised-button
color="accent"
[disabled]="!refinePrompt.trim() || (session.generating$ | async)"
(click)="refine()"
>
<mat-icon>auto_fix_high</mat-icon>
Refine
</button>
</div>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">Theme</h4>
<div class="flex flex-wrap gap-4">
<div *ngIf="result.theme.colors" class="space-y-1">
<span class="text-xs text-gray-500 block mb-1">Colors</span>
<div *ngFor="let item of result.theme.colors | keyvalue" class="flex items-center gap-2">
<span class="w-5 h-5 rounded-full border" [style.background]="item.value"></span>
<span class="text-xs text-gray-600">{{ item.key }}:</span>
<span class="text-xs font-mono text-gray-500">{{ item.value }}</span>
</div>
</div>
<div *ngIf="result.theme.fonts" class="space-y-1">
<span class="text-xs text-gray-500 block mb-1">Fonts</span>
<div *ngFor="let item of result.theme.fonts | keyvalue" class="flex items-center gap-2 text-xs text-gray-600">
<span>{{ item.key }}:</span>
<span class="font-mono text-gray-500">{{ item.value }}</span>
</div>
</div>
<div *ngIf="result.theme.spacing" class="space-y-1">
<span class="text-xs text-gray-500 block mb-1">Spacing</span>
<div *ngFor="let item of result.theme.spacing | keyvalue" class="flex items-center gap-2 text-xs text-gray-600">
<span>{{ item.key }}:</span>
<span class="font-mono text-gray-500">{{ item.value }}</span>
</div>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-accordion [multi]="true">
<mat-expansion-panel *ngFor="let page of result.pages" class="mb-2">
<mat-expansion-panel-header>
<mat-panel-title class="text-sm font-medium">
{{ page.title }}
</mat-panel-title>
<mat-panel-description class="text-xs text-gray-400">
/{{ page.slug }}
&middot;
{{ page.components.length }} component{{ page.components.length !== 1 ? 's' : '' }}
</mat-panel-description>
</mat-expansion-panel-header>
<div class="space-y-1">
<div *ngFor="let comp of page.components" class="flex items-center gap-2 py-1">
<mat-icon class="text-gray-400 text-lg">circle</mat-icon>
<span class="text-sm text-gray-700">{{ getLabel(comp.type) }}</span>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>
</div>
<div *ngIf="!(session.generating$ | async) && !(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.
</p>
</div>
</div>
</div>
</div>
`,
})
export class LlmWorkspaceComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(LlmApiService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
session = inject(LlmSessionService);
prompt = '';
refinePrompt = '';
showRefine = false;
siteId: string | null = null;
versions: GenerationVersion[] = [];
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.siteId = this.route.snapshot.paramMap.get('siteId');
const existingPrompt = this.session.currentPrompt;
if (existingPrompt) {
this.prompt = existingPrompt;
}
this.loadVersions();
}
private loadVersions(): void {
if (!this.siteId) return;
this.session.setLoadingVersions(true);
this.api.getVersions(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (v) => {
this.versions = v;
this.session.setVersions(v);
if (v.length > 0 && !this.session.currentResult) {
this.api.restoreVersion(v[0].id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (structure) => {
this.session.loadVersionData(structure, v[0].id);
},
error: () => this.session.setLoadingVersions(false),
});
} else if (v.length > 0 && !this.session.currentVersionId) {
this.session.setCurrentVersionId(v[0].id);
} else {
this.session.setLoadingVersions(false);
}
},
error: () => this.session.setLoadingVersions(false),
});
}
generate(): void {
if (!this.prompt.trim() || !this.siteId) return;
this.showRefine = false;
this.session.setPrompt(this.prompt);
this.session.setGenerating(true);
this.session.clearError();
this.api.generate({ prompt: this.prompt, siteId: this.siteId })
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.session.setResult(data);
this.session.setCurrentVersionId(null);
this.loadVersions();
this.snackBar.open('Site generated successfully!', 'Close', { duration: 3000 });
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
this.session.setError(msg);
},
});
}
refine(): void {
const result = this.session.currentResult;
if (!result || !this.refinePrompt.trim() || !this.siteId) return;
this.session.setGenerating(true);
this.session.clearError();
this.api.refine({
prompt: this.refinePrompt,
siteId: this.siteId,
currentStructure: result,
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
this.session.setResult(data);
this.session.setCurrentVersionId(null);
this.loadVersions();
this.refinePrompt = '';
this.showRefine = false;
this.snackBar.open('Site refined successfully!', 'Close', { duration: 3000 });
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to connect to server';
this.session.setError(msg);
},
});
}
deleteVersion(version: GenerationVersion): void {
if (this.versions.length <= 1) {
this.snackBar.open('Cannot delete the last version', 'Close', { duration: 3000 });
return;
}
const ref = this.dialog.open(DeleteVersionDialog, {
width: '400px',
data: { versionNumber: version.versionNumber },
});
ref.afterClosed().subscribe((confirmed) => {
if (!confirmed) return;
this.api.deleteVersion(version.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
if (this.session.currentVersionId === version.id) {
this.session.setCurrentVersionId(null);
}
this.loadVersions();
this.snackBar.open('Version deleted', 'Close', { duration: 2000 });
},
error: () => {
this.snackBar.open('Failed to delete version', 'Close', { duration: 3000 });
},
});
});
}
switchToVersion(version: GenerationVersion): void {
if (version.id === this.session.currentVersionId) return;
this.session.setGenerating(true);
this.api.restoreVersion(version.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (structure) => {
this.session.loadVersionData(structure, version.id);
},
error: (err) => {
const msg = err.error?.error || err.statusText || 'Failed to restore version';
this.session.setError(msg);
},
});
}
openPreview(): void {
const result = this.session.currentResult;
if (!result) return;
this.dialog.open(PreviewDialogComponent, {
data: result,
width: '100vw',
height: '100vh',
maxWidth: '100vw',
maxHeight: '100vh',
panelClass: 'fullscreen-dialog',
autoFocus: false,
});
}
retry(): void {
this.session.clearError();
this.generate();
}
goToDashboard(): void {
this.router.navigate(['/dashboard']);
}
totalComponents(structure: SiteStructure): number {
return structure.pages.reduce((sum, p) => sum + p.components.length, 0);
}
getLabel(type: string): string {
return getComponentLabel(type);
}
isCurrentVersion(versionId: string): boolean {
return this.session.currentVersionId === versionId;
}
get currentVersionPrompt(): string | null {
const version = this.versions.find(v => v.id === this.session.currentVersionId);
return version ? version.prompt : null;
}
truncatePrompt(prompt: string, maxLen: number = 60): string {
return prompt.length > maxLen ? prompt.slice(0, maxLen) + '...' : prompt;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}