generate site using llm prompt
This commit is contained in:
459
frontend/src/app/llm-workspace/llm-workspace.component.ts
Normal file
459
frontend/src/app/llm-workspace/llm-workspace.component.ts
Normal 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' : '' }}
|
||||
·
|
||||
{{ 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 }}
|
||||
·
|
||||
{{ 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user