Files
indie/frontend/src/app/llm-workspace/llm-workspace.component.ts
2026-07-12 00:10:15 +05:45

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