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: `
AI Site Generator
Describe your website idea
Briefly describe the type of site you want. The AI will suggest sections you can customize.
Provider {{ p }} {{ configuredProviders[0] }}

Analyzing your idea...

Finding the right sections for your site

{{ configStepTitle }}

{{ configStepSubtitle }}

Configure AI Provider

Add an API key to enable AI site generation. Keys are encrypted at rest.

Provider Gemini OpenAI / Compatible API Key Base URL (optional) For OpenAI-compatible providers like DeepSeek Model (optional) Override the default model name

Generating your site...

This usually takes 15-30 seconds

error_outline

Suggestion failed

{{ suggestError }}

error_outline

Generation failed

{{ error }}

History

{{ (session.versions$ | async)?.length || 0 }} version{{ ((session.versions$ | async)?.length || 0) !== 1 ? 's' : '' }}
v{{ v.versionNumber }} {{ v.createdAt | date:'short' }}

{{ truncatePrompt(v.prompt, 50) }}

No versions yet

Generated Site Structure

{{ result.pages.length }} page{{ result.pages.length !== 1 ? 's' : '' }}

Prompt

{{ currentVersionPrompt }}

Refinement instructions
Provider {{ p }} {{ configuredProviders[0] }}

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
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(); } }