From 6139f8221fda58da4521c1ed9178988adb697889 Mon Sep 17 00:00:00 2001 From: Krrish Ghimire Date: Sun, 26 Jul 2026 20:09:08 +0545 Subject: [PATCH] ask personalized questions for site creation --- .../krrishg/dto/SuggestSectionsResponse.java | 10 ++- .../java/com/krrishg/service/LLMService.java | 56 +++++++++++- .../krrishg/controller/LLMControllerTest.java | 3 +- .../com/krrishg/service/LLMServiceTest.java | 21 ++++- frontend/src/app/core/models/llm.ts | 14 +++ .../llm-workspace/llm-workspace.component.ts | 85 ++++++++++++++++++- .../section-configurator.component.ts | 81 ++++++++++++++++-- 7 files changed, 249 insertions(+), 21 deletions(-) diff --git a/backend/src/main/java/com/krrishg/dto/SuggestSectionsResponse.java b/backend/src/main/java/com/krrishg/dto/SuggestSectionsResponse.java index 3aede31..c8feda7 100644 --- a/backend/src/main/java/com/krrishg/dto/SuggestSectionsResponse.java +++ b/backend/src/main/java/com/krrishg/dto/SuggestSectionsResponse.java @@ -3,7 +3,8 @@ package com.krrishg.dto; import java.util.List; public record SuggestSectionsResponse( - List sections + List sections, + List 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 options + ) {} } \ No newline at end of file diff --git a/backend/src/main/java/com/krrishg/service/LLMService.java b/backend/src/main/java/com/krrishg/service/LLMService.java index 54fe399..219aa27 100644 --- a/backend/src/main/java/com/krrishg/service/LLMService.java +++ b/backend/src/main/java/com/krrishg/service/LLMService.java @@ -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 sections = new ArrayList<>(); - if (root.isArray()) { - for (JsonNode node : root) { + List 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 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"); diff --git a/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java b/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java index 53997c2..8ff9e80 100644 --- a/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java +++ b/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java @@ -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); diff --git a/backend/src/test/java/com/krrishg/service/LLMServiceTest.java b/backend/src/test/java/com/krrishg/service/LLMServiceTest.java index 691e2e8..b166d4a 100644 --- a/backend/src/test/java/com/krrishg/service/LLMServiceTest.java +++ b/backend/src/test/java/com/krrishg/service/LLMServiceTest.java @@ -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( diff --git a/frontend/src/app/core/models/llm.ts b/frontend/src/app/core/models/llm.ts index 4d491f2..e61d53c 100644 --- a/frontend/src/app/core/models/llm.ts +++ b/frontend/src/app/core/models/llm.ts @@ -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 { diff --git a/frontend/src/app/llm-workspace/llm-workspace.component.ts b/frontend/src/app/llm-workspace/llm-workspace.component.ts index 8e71bcc..805fca1 100644 --- a/frontend/src/app/llm-workspace/llm-workspace.component.ts +++ b/frontend/src/app/llm-workspace/llm-workspace.component.ts @@ -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'; @@ -184,7 +185,7 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating'; -
+
error_outline

Suggestion failed

@@ -193,6 +194,52 @@ type Phase = 'idle' | 'suggesting' | 'configuring' | 'generating';
+ + +
+ info +
+

API key required for {{ setupProvider }}

+

{{ suggestError }}

+
+
+
+ + Provider + + Gemini + OpenAI / Compatible + + + + API Key + + + + + Base URL (optional) + + For OpenAI-compatible providers like DeepSeek + + + Model (optional) + + Override the default model name + +
+ + +
+
+
+
+
error_outline
@@ -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(); 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(); }, diff --git a/frontend/src/app/llm-workspace/section-configurator/section-configurator.component.ts b/frontend/src/app/llm-workspace/section-configurator/section-configurator.component.ts index 99febd4..8e0c6ac 100644 --- a/frontend/src/app/llm-workspace/section-configurator/section-configurator.component.ts +++ b/frontend/src/app/llm-workspace/section-configurator/section-configurator.component.ts @@ -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: `
-
Sections
-
+
Content
+
+
Layout
+
Theme
Details
+
+
+

Tell us about your content

+

Answer a few questions so the AI can generate authentic content for your site.

+
+ +
+ + + + + + + Select... + {{ opt }} + + + + + +
+
+
@@ -189,7 +234,7 @@ const THEME_VIBES = [