755 lines
32 KiB
TypeScript
755 lines
32 KiB
TypeScript
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: `
|
|
<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>
|
|
<button mat-icon-button (click)="goToSettings()" title="Settings">
|
|
<mat-icon>settings</mat-icon>
|
|
</button>
|
|
</mat-toolbar>
|
|
|
|
<div class="flex-1 overflow-y-auto">
|
|
<div class="px-6 py-6 space-y-6">
|
|
|
|
<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 idea</mat-label>
|
|
<textarea
|
|
matInput
|
|
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">
|
|
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">
|
|
<mat-label>Provider</mat-label>
|
|
<mat-select [(ngModel)]="selectedProvider" class="text-sm">
|
|
<mat-option *ngFor="let p of configuredProviders" [value]="p" class="capitalize">{{ p }}</mat-option>
|
|
</mat-select>
|
|
</mat-form-field>
|
|
<span *ngIf="configuredProviders.length === 1" class="text-xs text-gray-500 capitalize mr-1">{{ configuredProviders[0] }}</span>
|
|
<button
|
|
mat-raised-button
|
|
color="primary"
|
|
[disabled]="!briefDescription.trim() || suggesting || configuredProviders.length === 0"
|
|
(click)="suggestSections()"
|
|
>
|
|
<mat-icon>auto_awesome</mat-icon>
|
|
Plan My Site
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</mat-card-content>
|
|
</mat-card>
|
|
|
|
<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">
|
|
Add an API key to enable AI site generation. Keys are encrypted at rest.
|
|
</p>
|
|
<div class="space-y-3">
|
|
<mat-form-field appearance="outline" class="w-full">
|
|
<mat-label>Provider</mat-label>
|
|
<mat-select [(ngModel)]="setupProvider">
|
|
<mat-option value="gemini">Gemini</mat-option>
|
|
<mat-option value="openai">OpenAI / Compatible</mat-option>
|
|
</mat-select>
|
|
</mat-form-field>
|
|
<mat-form-field appearance="outline" class="w-full">
|
|
<mat-label>API Key</mat-label>
|
|
<input matInput [type]="setupKeyVisible ? 'text' : 'password'" [(ngModel)]="setupApiKey" />
|
|
<button mat-icon-button matSuffix (click)="setupKeyVisible = !setupKeyVisible" type="button"
|
|
[attr.aria-label]="setupKeyVisible ? 'Hide key' : 'Show key'">
|
|
<mat-icon>{{ setupKeyVisible ? 'visibility_off' : 'visibility' }}</mat-icon>
|
|
</button>
|
|
</mat-form-field>
|
|
<mat-form-field appearance="outline" class="w-full" *ngIf="setupProvider === 'openai'">
|
|
<mat-label>Base URL (optional)</mat-label>
|
|
<input matInput placeholder="https://api.deepseek.com/v1" [(ngModel)]="setupBaseUrl" />
|
|
<mat-hint>For OpenAI-compatible providers like DeepSeek</mat-hint>
|
|
</mat-form-field>
|
|
<mat-form-field appearance="outline" class="w-full">
|
|
<mat-label>Model (optional)</mat-label>
|
|
<input matInput placeholder="{{ setupProvider === 'gemini' ? 'gemini-2.0-flash-exp' : 'gpt-4o-mini' }}" [(ngModel)]="setupModel" />
|
|
<mat-hint>Override the default model name</mat-hint>
|
|
</mat-form-field>
|
|
<div class="flex justify-end">
|
|
<button mat-flat-button color="primary" [disabled]="!setupApiKey.trim() || setupSaving" (click)="saveSetupKey()">
|
|
<mat-icon>save</mat-icon>
|
|
{{ setupSaving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</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="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">
|
|
<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>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
*ngIf="((session.versions$ | async)?.length || 0) > 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
|
|
</button>
|
|
<span class="text-xs text-gray-400">{{ (session.versions$ | async)?.length || 0 }} version{{ ((session.versions$ | async)?.length || 0) !== 1 ? 's' : '' }}</span>
|
|
</div>
|
|
</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 (session.versions$ | async) || []"
|
|
(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="((session.versions$ | async) || []).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' : '' }}
|
|
</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 items-center gap-2 mt-2">
|
|
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
|
|
<mat-label>Provider</mat-label>
|
|
<mat-select [(ngModel)]="selectedProvider" class="text-sm">
|
|
<mat-option *ngFor="let p of configuredProviders" [value]="p" class="capitalize">{{ p }}</mat-option>
|
|
</mat-select>
|
|
</mat-form-field>
|
|
<span *ngIf="configuredProviders.length === 1" class="text-xs text-gray-500 capitalize mr-1">{{ configuredProviders[0] }}</span>
|
|
<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 }}
|
|
·
|
|
Raw HTML
|
|
</mat-panel-description>
|
|
</mat-expansion-panel-header>
|
|
<div class="flex items-center gap-2 py-1">
|
|
<mat-icon class="text-gray-400 text-lg">code</mat-icon>
|
|
<span class="text-sm text-gray-400 italic">Raw HTML content</span>
|
|
</div>
|
|
</mat-expansion-panel>
|
|
</mat-accordion>
|
|
</div>
|
|
</div>
|
|
|
|
<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. It will suggest pages and sections that you can customize before generating.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
})
|
|
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<string, string> = {};
|
|
llmModels: Record<string, string> = {};
|
|
configuredProviders: string[] = [];
|
|
selectedProvider = '';
|
|
|
|
setupProvider = 'gemini';
|
|
setupApiKey = '';
|
|
setupBaseUrl = '';
|
|
setupModel = '';
|
|
setupSaving = false;
|
|
setupKeyVisible = false;
|
|
|
|
private destroy$ = new Subject<void>();
|
|
|
|
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<string, string> = {};
|
|
const models: Record<string, string> = {};
|
|
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();
|
|
}
|
|
}
|