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 { MatSelectModule } from '@angular/material/select';
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 { LlmConfigService } from '../core/services/llm-config.service';
import { LlmSessionService } from './llm-session.service';
import { GenerationService } from './generation.service';
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,
imports: [
NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule,
MatToolbarModule, MatButtonModule, MatIconModule,
MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule,
MatSnackBarModule, MatCardModule,
MatDividerModule, MatExpansionModule, MatDialogModule,
SectionConfiguratorComponent,
],
styles: [`
.provider-select { width: 140px; }
.provider-select .mat-mdc-form-field-subscript-wrapper { display: none; }
`],
template: `
arrow_back
AI Site Generator
settings
Describe your website idea
Briefly describe the type of site you want. The AI will suggest sections you can customize.
1">
Provider
{{ p }}
{{ configuredProviders[0] }}
auto_awesome
Plan My Site
Analyzing your idea...
Finding the right sections for your site
{{ configStepTitle }}
{{ configStepSubtitle }}
arrow_back
Back
Configure AI Provider
Add an API key to enable AI site generation. Keys are encrypted at rest.
Generating your site...
This usually takes 15-30 seconds
error_outline
Suggestion failed
{{ suggestError }}
Dismiss
error_outline
Generation failed
{{ error }}
Try Again
History
10"
mat-stroked-button
class="!text-[10px] !leading-none !min-w-0 !px-2 !py-0.5 !h-auto"
color="warn"
(click)="pruneOldVersions()"
title="Keep only the 10 most recent versions"
>
Clear old
{{ (session.versions$ | async)?.length || 0 }} version{{ ((session.versions$ | async)?.length || 0) !== 1 ? 's' : '' }}
v{{ v.versionNumber }}
{{ v.createdAt | date:'short' }}
delete
{{ truncatePrompt(v.prompt, 50) }}
No versions yet
Generated Site Structure
{{ result.pages.length }} page{{ result.pages.length !== 1 ? 's' : '' }}
visibility
Preview
refresh
Refine
Prompt
{{ currentVersionPrompt }}
Refinement instructions
1">
Provider
{{ p }}
{{ configuredProviders[0] }}
auto_fix_high
Refine
Theme
Colors
{{ item.key }}:
{{ item.value }}
Fonts
{{ item.key }}:
{{ item.value }}
Spacing
{{ item.key }}:
{{ item.value }}
{{ page.title }}
/{{ page.slug }}
·
Raw HTML
code
Raw HTML content
0 && currentPhase === 'idle' && !(session.currentResult$ | async) && !(session.error$ | async)" class="flex flex-col items-center justify-center py-16 text-gray-400">
auto_awesome
Describe your dream site
Tell the AI what kind of website you want. It will suggest pages and sections that you can customize before generating.
`,
})
export class LlmWorkspaceComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(LlmApiService);
private llmConfigService = inject(LlmConfigService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
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;
siteId: string | null = null;
llmKeyStatus: LLMKeyStatus = {};
llmBaseUrls: Record = {};
llmModels: Record = {};
configuredProviders: string[] = [];
selectedProvider = '';
setupProvider = 'gemini';
setupApiKey = '';
setupBaseUrl = '';
setupModel = '';
setupSaving = false;
setupKeyVisible = false;
private destroy$ = new Subject();
ngOnInit(): void {
this.generationService.refreshVersions$
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.loadVersions());
this.route.paramMap
.pipe(takeUntil(this.destroy$))
.subscribe(params => {
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();
}
});
}
private loadLlmConfig(): void {
this.llmConfigService.getStatus()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (status) => {
this.llmKeyStatus = status;
const urls: Record = {};
const models: Record = {};
this.configuredProviders = Object.keys(status).filter(k => {
if (status[k]?.baseUrl) urls[k] = status[k].baseUrl!;
if (status[k]?.model) models[k] = status[k].model!;
return status[k]?.configured;
});
this.llmBaseUrls = urls;
this.llmModels = models;
if (this.configuredProviders.length === 1) {
this.selectedProvider = this.configuredProviders[0];
} else if (this.configuredProviders.length > 1 && !this.selectedProvider) {
this.selectedProvider = this.configuredProviders[0];
}
},
error: () => {},
});
}
private loadVersions(): void {
if (!this.siteId) return;
this.session.setLoadingVersions(true);
this.api.getVersions(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (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),
});
}
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.sectionConfig || !this.siteId || !this.selectedProvider) return;
const compiledPrompt = this.buildPrompt();
this.prompt = compiledPrompt;
this.currentPhase = 'generating';
this.showRefine = false;
this.session.setPrompt(compiledPrompt);
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.generationService.generate({
prompt: compiledPrompt,
siteId: this.siteId,
provider: this.selectedProvider,
baseUrl,
model,
}, 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;
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.generationService.refine({
prompt: this.refinePrompt,
siteId: this.siteId,
provider: this.selectedProvider,
baseUrl,
model,
currentStructure: result,
}, this.siteId);
this.refinePrompt = '';
this.showRefine = false;
}
deleteVersion(version: GenerationVersion): void {
if (this.session.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 });
},
});
});
}
pruneOldVersions(): void {
if (!this.siteId) return;
this.api.pruneVersions(this.siteId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (result) => {
this.loadVersions();
if (result.deleted > 0) {
this.snackBar.open(`Cleaned up ${result.deleted} old versions`, 'Close', { duration: 3000 });
}
},
error: () => {
this.snackBar.open('Failed to clean up versions', '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']);
}
goToSettings(): void {
if (this.siteId) this.router.navigate(['/llm', this.siteId, 'settings']);
}
isCurrentVersion(versionId: string): boolean {
return this.session.currentVersionId === versionId;
}
get currentVersionPrompt(): string | null {
const version = this.session.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;
}
saveSetupKey(): void {
const key = this.setupApiKey?.trim();
const baseUrl = this.setupBaseUrl?.trim() || undefined;
const model = this.setupModel?.trim() || undefined;
if (!key) return;
this.setupSaving = true;
this.llmConfigService.saveKey(this.setupProvider, key, baseUrl, model)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.setupSaving = false;
this.setupApiKey = '';
this.setupBaseUrl = '';
this.setupModel = '';
this.snackBar.open(`${this.setupProvider} API key saved`, 'Close', { duration: 2000 });
this.loadLlmConfig();
},
error: () => {
this.setupSaving = false;
this.snackBar.open('Failed to save API key', 'Close', { duration: 3000 });
},
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}