ask personalized questions for site creation

This commit is contained in:
Krrish Ghimire
2026-07-26 20:09:08 +05:45
parent 1ad839a013
commit 6139f8221f
7 changed files with 249 additions and 21 deletions

View File

@@ -3,7 +3,8 @@ package com.krrishg.dto;
import java.util.List;
public record SuggestSectionsResponse(
List<SectionSuggestion> sections
List<SectionSuggestion> sections,
List<PersonalizedQuestion> personalizedQuestions
) {
public record SectionSuggestion(
String title,
@@ -16,4 +17,11 @@ public record SuggestSectionsResponse(
String title,
String description
) {}
public record PersonalizedQuestion(
String id,
String question,
String type,
List<String> options
) {}
}

View File

@@ -617,12 +617,14 @@ public class LLMService {
}
private SuggestSectionsResponse parseSectionSuggestions(String llmOutput) {
String json = extractJsonArray(llmOutput);
String json = extractJsonObject(llmOutput);
try {
JsonNode root = objectMapper.readTree(json);
List<SuggestSectionsResponse.SectionSuggestion> sections = new ArrayList<>();
if (root.isArray()) {
for (JsonNode node : root) {
List<SuggestSectionsResponse.PersonalizedQuestion> questions = new ArrayList<>();
if (root.has("sections") && root.get("sections").isArray()) {
for (JsonNode node : root.get("sections")) {
String title = node.has("title") ? node.get("title").asText() : null;
String description = node.has("description") ? node.get("description").asText() : null;
String slug = node.has("slug") ? node.get("slug").asText() : null;
@@ -641,15 +643,61 @@ public class LLMService {
}
}
}
if (root.has("personalizedQuestions") && root.get("personalizedQuestions").isArray()) {
for (JsonNode node : root.get("personalizedQuestions")) {
String id = node.has("id") ? node.get("id").asText() : null;
String question = node.has("question") ? node.get("question").asText() : null;
String type = node.has("type") ? node.get("type").asText() : "text";
if (id != null && question != null) {
List<String> options = null;
if (node.has("options") && node.get("options").isArray()) {
options = new ArrayList<>();
for (JsonNode opt : node.get("options")) {
options.add(opt.asText());
}
}
questions.add(new SuggestSectionsResponse.PersonalizedQuestion(id, question, type, options));
}
}
}
if (sections.isEmpty()) {
throw new IllegalArgumentException("LLM output must contain at least one section suggestion");
}
return new SuggestSectionsResponse(sections);
return new SuggestSectionsResponse(sections, questions);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to parse section suggestions as JSON: " + e.getMessage());
}
}
private String extractJsonObject(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("LLM output is empty");
}
String trimmed = raw.trim();
if (trimmed.startsWith("```")) {
int start = trimmed.indexOf('\n');
int end = trimmed.lastIndexOf("```");
if (start > 0 && end > start) {
trimmed = trimmed.substring(start, end).trim();
}
}
if (!trimmed.startsWith("{")) {
int brace = trimmed.indexOf('{');
if (brace >= 0) {
trimmed = trimmed.substring(brace);
}
}
if (!trimmed.endsWith("}")) {
int brace = trimmed.lastIndexOf('}');
if (brace >= 0) {
trimmed = trimmed.substring(0, brace + 1);
}
}
return trimmed;
}
private String extractJsonArray(String raw) {
if (raw == null || raw.isBlank()) {
throw new IllegalArgumentException("LLM output is empty");

View File

@@ -380,7 +380,8 @@ class LLMControllerTest {
List.of(
new SuggestSectionsResponse.SectionSuggestion("Home", "Hero section", "home", List.of()),
new SuggestSectionsResponse.SectionSuggestion("About", "Bio", "about", List.of())
)
),
List.of()
);
when(llmService.suggestSections(any(), anyString(), anyString(), any(), any(), any())).thenReturn(response);

View File

@@ -502,7 +502,8 @@ class LLMServiceTest {
@Test
void suggestSectionsReturnsParsedSuggestions() {
geminiProvider.withContent("""
[
{
"sections": [
{"title": "Home", "description": "Hero section", "slug": "home",
"patterns": [
{"title": "Full-screen hero", "description": "Large image with text overlay"},
@@ -513,7 +514,12 @@ class LLMServiceTest {
{"title": "Timeline", "description": "Vertical story timeline"}
]},
{"title": "Gallery", "description": "Portfolio grid", "slug": "gallery", "patterns": []}
]
],
"personalizedQuestions": [
{"id": "name", "question": "What is your name?", "type": "text"},
{"id": "bio", "question": "Write a short bio", "type": "textarea"}
]
}
""");
SuggestSectionsResponse response = llmService.suggestSections(
@@ -527,6 +533,10 @@ class LLMServiceTest {
assertEquals("Full-screen hero", response.sections().get(0).patterns().get(0).title());
assertEquals("Timeline", response.sections().get(1).patterns().get(0).title());
assertEquals(0, response.sections().get(2).patterns().size());
assertEquals(2, response.personalizedQuestions().size());
assertEquals("name", response.personalizedQuestions().get(0).id());
assertEquals("textarea", response.personalizedQuestions().get(1).type());
}
@Test
@@ -540,9 +550,12 @@ class LLMServiceTest {
@Test
void suggestSectionsUsesProvidedApiKey() {
geminiProvider.withContent("""
[
{
"sections": [
{"title": "Home", "description": "Hero", "slug": "home", "patterns": []}
]
],
"personalizedQuestions": []
}
""");
SuggestSectionsResponse response = llmService.suggestSections(

View File

@@ -93,8 +93,22 @@ export interface SuggestSectionsRequest {
model?: string;
}
export interface PersonalizedQuestion {
id: string;
question: string;
type: string;
options?: string[];
}
export interface QuestionAnswer {
id: string;
question: string;
answer: string;
}
export interface SuggestSectionsResponse {
sections: SectionSuggestion[];
personalizedQuestions?: PersonalizedQuestion[];
}
export interface SelectedSection {

View File

@@ -20,7 +20,7 @@ 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 { SiteStructure, GenerationVersion, LLMKeyStatus, SectionSuggestion, PersonalizedQuestion } 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';
@@ -123,6 +123,7 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
<app-section-configurator
[suggestions]="sectionSuggestions"
[personalizedQuestions]="personalizedQuestions"
(configComplete)="onConfigComplete($event)"
(stepChange)="onConfigStepChange($event)"
></app-section-configurator>
@@ -184,7 +185,7 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
</div>
</div>
<div *ngIf="suggestError" class="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<div *ngIf="suggestError && !showMissingKeyForm" 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>
@@ -193,6 +194,52 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
</div>
</div>
<mat-card *ngIf="showMissingKeyForm" class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-start gap-3 mb-4">
<mat-icon class="text-amber-500 mt-0.5">info</mat-icon>
<div>
<p class="text-sm font-medium text-amber-800">API key required for {{ setupProvider }}</p>
<p class="text-sm text-amber-700 mt-1">{{ suggestError }}</p>
</div>
</div>
<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 gap-2">
<button mat-button (click)="showMissingKeyForm = false; suggestError = null">Cancel</button>
<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.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">
@@ -398,6 +445,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
briefDescription = '';
sectionSuggestions: SectionSuggestion[] = [];
personalizedQuestions: PersonalizedQuestion[] = [];
sectionConfig: SectionConfig | null = null;
configStepTitle = 'Customize Your Site';
configStepSubtitle = '';
@@ -422,6 +470,8 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
setupSaving = false;
setupKeyVisible = false;
showMissingKeyForm = false;
private destroy$ = new Subject<void>();
ngOnInit(): void {
@@ -441,6 +491,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
this.showRefine = false;
this.currentPhase = 'idle';
this.sectionSuggestions = [];
this.personalizedQuestions = [];
this.sectionConfig = null;
this.suggestError = null;
this.session.clearHistory();
@@ -519,12 +570,19 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
}).pipe(takeUntil(this.destroy$)).subscribe({
next: (response) => {
this.sectionSuggestions = response.sections;
this.personalizedQuestions = response.personalizedQuestions || [];
this.suggesting = false;
this.currentPhase = 'configuring';
},
error: (err) => {
this.suggesting = false;
this.suggestError = err.error?.error || err.statusText || 'Failed to get section suggestions';
const errorMsg = err.error?.error || err.statusText || 'Failed to get section suggestions';
this.suggestError = errorMsg;
const match = errorMsg.match(/No API key configured for (\w+)/);
if (match) {
this.showMissingKeyForm = true;
this.setupProvider = match[1];
}
},
});
}
@@ -535,7 +593,13 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
}
onConfigStepChange(step: string): void {
if (step.startsWith('section-')) {
if (step === 'questions') {
this.configStepTitle = 'Tell Us About Your Content';
this.configStepSubtitle = 'Answer a few questions so the AI can generate authentic content.';
} else if (step === 'sections') {
this.configStepTitle = 'Choose Layout Patterns';
this.configStepSubtitle = 'Pick a layout for each section, or describe your own.';
} else if (step.startsWith('section-')) {
const idx = parseInt(step.split('-')[1], 10);
const section = this.sectionSuggestions[idx];
this.configStepTitle = section ? `Configure ${section.title}` : 'Configure Sections';
@@ -552,6 +616,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
backToIdle(): void {
this.currentPhase = 'idle';
this.sectionSuggestions = [];
this.personalizedQuestions = [];
this.sectionConfig = null;
}
@@ -586,6 +651,15 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
lines.push(`Theme direction: ${config.themeVibe}`);
}
const answeredQuestions = config.questionAnswers.filter(qa => qa.answer.trim());
if (answeredQuestions.length > 0) {
lines.push('');
lines.push('Site content details:');
for (const qa of answeredQuestions) {
lines.push(`- ${qa.question}: ${qa.answer.trim()}`);
}
}
lines.push('');
lines.push('Pages:');
@@ -711,6 +785,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
}
goToSettings(): void {
if (this.configuredProviders.length === 0) return;
if (this.siteId) this.router.navigate(['/llm', this.siteId, 'settings']);
}
@@ -741,6 +816,8 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
this.setupApiKey = '';
this.setupBaseUrl = '';
this.setupModel = '';
this.showMissingKeyForm = false;
this.suggestError = null;
this.snackBar.open(`${this.setupProvider} API key saved`, 'Close', { duration: 2000 });
this.loadLlmConfig();
},

View File

@@ -6,12 +6,23 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { SectionSuggestion, SelectedSection, PatternSuggestion } from '../../core/models/llm';
import { SectionSuggestion, PatternSuggestion, PersonalizedQuestion, QuestionAnswer } from '../../core/models/llm';
export interface SelectedSection {
title: string;
description: string;
slug: string;
selected: boolean;
customNotes: string;
selectedPattern: string | null;
customPatternDesc: string;
}
export interface SectionConfig {
sections: SelectedSection[];
themeVibe: string;
additionalRequirements: string;
questionAnswers: QuestionAnswer[];
}
const THEME_VIBES = [
@@ -47,13 +58,47 @@ const THEME_VIBES = [
`],
template: `
<div class="flex items-center mb-6">
<div class="crumb" [class.active]="phase === 'sections'" [class.completed]="phase !== 'sections'">Sections</div>
<div class="crumb-line" [class.completed]="phase !== 'sections'"></div>
<div class="crumb" [class.active]="phase === 'questions'" [class.completed]="phase !== 'questions'">Content</div>
<div class="crumb-line" [class.completed]="phase !== 'questions'"></div>
<div class="crumb" [class.active]="phase === 'sections'" [class.completed]="phase === 'theme' || phase === 'details'">Layout</div>
<div class="crumb-line" [class.completed]="phase === 'theme' || phase === 'details'"></div>
<div class="crumb" [class.active]="phase === 'theme'" [class.completed]="phase === 'details'">Theme</div>
<div class="crumb-line" [class.completed]="phase === 'details'"></div>
<div class="crumb" [class.active]="phase === 'details'">Details</div>
</div>
<div *ngIf="phase === 'questions'" class="space-y-5">
<div>
<h4 class="text-base font-semibold text-gray-900 mb-1">Tell us about your content</h4>
<p class="text-sm text-gray-500 mb-4">Answer a few questions so the AI can generate authentic content for your site.</p>
</div>
<div *ngFor="let q of personalizedQuestions; let qi = index" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700">{{ q.question }}</label>
<mat-form-field appearance="outline" class="w-full" *ngIf="q.type === 'textarea'">
<textarea
matInput
rows="3"
[placeholder]="'Enter your ' + q.id.replace('-', ' ')"
[(ngModel)]="questionAnswers[qi].answer"
></textarea>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full" *ngIf="q.type === 'select'">
<mat-select [(ngModel)]="questionAnswers[qi].answer">
<mat-option value="">Select...</mat-option>
<mat-option *ngFor="let opt of q.options" [value]="opt">{{ opt }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full" *ngIf="q.type === 'text' || (!q.type || q.type === '')">
<input
matInput
[placeholder]="'Enter your ' + q.id.replace('-', ' ')"
[(ngModel)]="questionAnswers[qi].answer"
/>
</mat-form-field>
</div>
</div>
<div *ngIf="phase === 'sections' && currentSection" class="space-y-4">
<div class="flex items-center justify-between">
<div>
@@ -189,7 +234,7 @@ const THEME_VIBES = [
<button
mat-stroked-button
(click)="prev()"
[disabled]="phase === 'sections' && sectionIndex === 0"
[disabled]="phase === 'questions'"
>
<mat-icon>arrow_back</mat-icon>
Back
@@ -213,12 +258,14 @@ const THEME_VIBES = [
})
export class SectionConfiguratorComponent implements OnInit {
@Input() suggestions: SectionSuggestion[] = [];
@Input() personalizedQuestions: PersonalizedQuestion[] = [];
@Output() configComplete = new EventEmitter<SectionConfig>();
@Output() stepChange = new EventEmitter<string>();
phase: 'sections' | 'theme' | 'details' = 'sections';
phase: 'questions' | 'sections' | 'theme' | 'details' = 'questions';
sectionIndex = 0;
sections: SelectedSection[] = [];
questionAnswers: QuestionAnswer[] = [];
themeVibes = THEME_VIBES;
selectedVibe = 'Any';
@@ -235,6 +282,14 @@ export class SectionConfiguratorComponent implements OnInit {
selectedPattern: s.patterns?.length ? s.patterns[0].title : null,
customPatternDesc: '',
}));
this.questionAnswers = this.personalizedQuestions.map(q => ({
id: q.id,
question: q.question,
answer: '',
}));
if (this.personalizedQuestions.length === 0) {
this.phase = 'sections';
}
}
get currentSection(): SelectedSection | null {
@@ -260,6 +315,9 @@ export class SectionConfiguratorComponent implements OnInit {
}
get navLabel(): string {
if (this.phase === 'questions') {
return `${this.personalizedQuestions.length} question${this.personalizedQuestions.length !== 1 ? 's' : ''}`;
}
if (this.phase === 'sections') {
return `Section ${this.sectionIndex + 1} of ${this.sections.length}`;
}
@@ -284,10 +342,14 @@ export class SectionConfiguratorComponent implements OnInit {
}
prev(): void {
if (this.phase === 'sections') {
if (this.phase === 'questions') {
} else if (this.phase === 'sections') {
if (this.sectionIndex > 0) {
this.sectionIndex--;
this.stepChange.emit(`section-${this.sectionIndex}`);
} else {
this.phase = 'questions';
this.stepChange.emit('questions');
}
} else if (this.phase === 'theme') {
this.phase = 'sections';
@@ -300,7 +362,11 @@ export class SectionConfiguratorComponent implements OnInit {
}
next(): void {
if (this.phase === 'sections') {
if (this.phase === 'questions') {
this.phase = 'sections';
this.sectionIndex = 0;
this.stepChange.emit('sections');
} else if (this.phase === 'sections') {
if (this.sectionIndex < this.sections.length - 1) {
this.sectionIndex++;
this.stepChange.emit(`section-${this.sectionIndex}`);
@@ -319,6 +385,7 @@ export class SectionConfiguratorComponent implements OnInit {
sections: this.sections,
themeVibe: vibe,
additionalRequirements: this.additionalRequirements,
questionAnswers: this.questionAnswers,
});
}
}