From 1025201edc41678eb69e55f1e05a299505c2bb65 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 2 Mar 2026 11:27:38 +0530 Subject: [PATCH 001/287] wip --- builder/ai_page_generator.py | 452 ++++++++++++++++++ frontend/components.d.ts | 1 + .../src/components/AIPageGeneratorModal.vue | 336 +++++++++++++ frontend/src/components/BuilderToolbar.vue | 20 +- frontend/src/pages/PageBuilder.vue | 24 + pyproject.toml | 1 + 6 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 builder/ai_page_generator.py create mode 100644 frontend/src/components/AIPageGeneratorModal.vue diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py new file mode 100644 index 000000000..e237e625c --- /dev/null +++ b/builder/ai_page_generator.py @@ -0,0 +1,452 @@ +""" +AI Page Generator +Generates Builder pages from natural language prompts using various LLM providers +""" + +import json +from typing import Any + +import frappe +from frappe import _ + + +def get_available_models(): + """Return list of available AI models""" + return [ + { + "provider": "openai", + "models": [ + {"name": "chatgpt-4o-latest", "label": "GPT-4o Latest", "max_tokens": 128000}, + {"name": "gpt-4o", "label": "GPT-4o", "max_tokens": 128000}, + {"name": "gpt-4o-mini", "label": "GPT-4o Mini", "max_tokens": 128000}, + {"name": "o1", "label": "O1 (Reasoning)", "max_tokens": 100000}, + {"name": "o1-mini", "label": "O1 Mini (Reasoning)", "max_tokens": 65000}, + {"name": "gpt-4-turbo", "label": "GPT-4 Turbo", "max_tokens": 128000}, + ], + }, + { + "provider": "anthropic", + "models": [ + {"name": "claude-sonnet-4-6", "label": "Claude Sonnet 4.6 (Latest)", "max_tokens": 200000}, + {"name": "claude-opus-4-6", "label": "Claude Opus 4.6 (Most Capable)", "max_tokens": 200000}, + {"name": "claude-haiku-4-5", "label": "Claude Haiku 4.5 (Fastest)", "max_tokens": 200000}, + ], + }, + { + "provider": "google", + "models": [ + {"name": "gemini-1.5-flash", "label": "Gemini 1.5 Flash", "max_tokens": 1048576}, + {"name": "gemini-1.5-pro", "label": "Gemini 1.5 Pro", "max_tokens": 2097152}, + { + "name": "gemini-2.0-flash-exp", + "label": "Gemini 2.0 Flash (Experimental)", + "max_tokens": 1048576, + }, + ], + }, + { + "provider": "x-ai", + "models": [ + {"name": "grok-beta", "label": "Grok Beta", "max_tokens": 131072}, + {"name": "grok-2-1212", "label": "Grok 2", "max_tokens": 131072}, + {"name": "grok-2-vision-1212", "label": "Grok 2 Vision", "max_tokens": 32768}, + ], + }, + ] + + +def get_system_prompt(): + """Return the system prompt for page generation""" + return """You are an expert web designer and developer specializing in creating beautiful, modern, and responsive web pages using the Frappe Builder block system. + +Your task is to generate a complete page structure based on user prompts. You must return ONLY a valid JSON array of blocks, with no additional text, markdown formatting, or explanations. + +# Block Structure Rules: + +1. **Root Block**: Always wrap everything in a root body element: +```json +{ + "element": "body", + "blockId": "root", + "children": [...], + "baseStyles": {} +} +``` + +2. **Common Elements**: Use semantic HTML elements: + - Container: "div" with display: flex/grid + - Text: "h1", "h2", "h3", "p", "span" + - Buttons: "button" or "a" + - Images: "img" with src attribute + - Sections: "section", "header", "footer", "nav" + +3. **Styling (baseStyles)**: Use CSS-in-JS object format: +```json +{ + "baseStyles": { + "display": "flex", + "flexDirection": "column", + "padding": "2rem", + "backgroundColor": "#ffffff", + "fontSize": "16px", + "fontWeight": "600" + } +} +``` + +4. **Responsive Styles**: Add mobile/tablet overrides: +```json +{ + "mobileStyles": { + "fontSize": "14px", + "padding": "1rem" + }, + "tabletStyles": { + "fontSize": "15px" + } +} +``` + +5. **Attributes**: Add HTML attributes: +```json +{ + "attributes": { + "src": "/path/to/image.jpg", + "alt": "Image description", + "href": "https://example.com", + "target": "_blank" + } +} +``` + +6. **Text Content**: Use innerText or innerHTML: +```json +{ + "element": "h1", + "innerText": "Welcome to Our Site" +} +``` + +7. **Classes**: Add CSS classes if needed: +```json +{ + "classes": ["hero-section", "text-center"] +} +``` + +# Design Best Practices: + +1. **Modern Design**: Use contemporary design patterns (flexbox, grid layouts) +2. **Color Schemes**: Use harmonious color palettes +3. **Typography**: Use appropriate font sizes and weights +4. **Spacing**: Apply consistent padding and margins +5. **Responsive**: Ensure mobile-first responsive design +6. **Accessibility**: Include proper semantic HTML and alt texts + +# Common Layouts: + +**Hero Section**: +```json +{ + "element": "section", + "baseStyles": { + "display": "flex", + "flexDirection": "column", + "alignItems": "center", + "justifyContent": "center", + "minHeight": "80vh", + "padding": "4rem 2rem", + "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + "color": "#ffffff" + }, + "children": [...] +} +``` + +**Card Grid**: +```json +{ + "element": "div", + "baseStyles": { + "display": "grid", + "gridTemplateColumns": "repeat(auto-fit, minmax(300px, 1fr))", + "gap": "2rem", + "padding": "2rem" + }, + "children": [...] +} +``` + +**Navigation Bar**: +```json +{ + "element": "nav", + "baseStyles": { + "display": "flex", + "justifyContent": "space-between", + "alignItems": "center", + "padding": "1rem 2rem", + "backgroundColor": "#ffffff", + "boxShadow": "0 2px 4px rgba(0,0,0,0.1)" + }, + "children": [...] +} +``` + +# CRITICAL: Output Format + +Return ONLY a JSON array with no markdown code blocks, no explanations, no additional text. Start directly with `[` and end with `]`. + +Example output: +[{"element":"body","blockId":"root","children":[{"element":"section","baseStyles":{"display":"flex"},"children":[...]}],"baseStyles":{}}] + +Remember: +- Generate complete, production-ready pages +- Use modern design principles +- Ensure responsive design +- Return ONLY valid JSON +- No markdown, no explanations, just JSON""" + + +def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> list[dict[str, Any]]: + """ + Generate page blocks from a prompt using the specified AI model + + Args: + prompt: User's description of the page they want + model: Model identifier (e.g., 'gpt-4o', 'claude-3-5-sonnet-20241022') + api_key: Optional API key (if not configured in site config) + + Returns: + List of block dictionaries + """ + try: + # Try to import litellm + try: + import litellm + except ImportError: + frappe.throw( + _("litellm library is not installed. Please install it using: pip install litellm"), + title=_("Missing Dependency"), + ) + + # Get API key from site config if not provided + if not api_key: + api_key = get_api_key_for_model(model) + + if not api_key: + frappe.throw( + _( + "API key not configured for model: {0}. Please configure it in site config or provide it in the request." + ).format(model), + title=_("API Key Missing"), + ) + + # Set the API key + set_api_key_for_provider(model, api_key) + + # Send progress update + frappe.publish_realtime( + "ai_generation_progress", + {"status": "preparing", "message": "Preparing AI request..."}, + user=frappe.session.user, + ) + + # Prepare messages + messages = [ + {"role": "system", "content": get_system_prompt()}, + {"role": "user", "content": f"Create a web page for: {prompt}"}, + ] + + # Send progress update + frappe.publish_realtime( + "ai_generation_progress", + {"status": "generating", "message": f"Generating page with {model}..."}, + user=frappe.session.user, + ) + + # Call the LLM + response = litellm.completion( + model=model, + messages=messages, + temperature=0.7, + max_tokens=8000, + ) + + # Send progress update + frappe.publish_realtime( + "ai_generation_progress", + {"status": "parsing", "message": "Parsing generated content..."}, + user=frappe.session.user, + ) + + # Extract content + content = response.choices[0].message.content.strip() + + # Try to parse as JSON + # Remove markdown code blocks if present + if content.startswith("```"): + # Remove ```json or ``` from start + content = content.split("\n", 1)[1] if "\n" in content else content[3:] + # Remove ``` from end + if content.endswith("```"): + content = content[:-3].strip() + + # Parse JSON + blocks = json.loads(content) + + # Validate it's a list + if not isinstance(blocks, list): + frappe.throw( + _("AI response is not a valid block array"), + title=_("Invalid Response"), + ) + + # Ensure we have a root block + if not blocks or blocks[0].get("element") != "body": + blocks = [ + { + "element": "body", + "blockId": "root", + "children": blocks, + "baseStyles": {}, + } + ] + + # Send completion update + frappe.publish_realtime( + "ai_generation_progress", + {"status": "complete", "message": "Page generated successfully!"}, + user=frappe.session.user, + ) + + return blocks + + except json.JSONDecodeError as e: + frappe.log_error(f"JSON parsing error: {str(e)}\nContent: {content}", "AI Page Generation Error") + frappe.throw( + _("Failed to parse AI response as JSON. The model may have returned invalid output."), + title=_("Generation Error"), + ) + + except Exception as e: + frappe.log_error(f"AI generation error: {str(e)}", "AI Page Generation Error") + frappe.throw( + _("Failed to generate page: {0}").format(str(e)), + title=_("Generation Error"), + ) + + +def get_api_key_for_model(model: str) -> str | None: + """Get API key from site config based on model provider""" + provider = get_provider_from_model(model) + + key_mapping = { + "openai": "openai_api_key", + "anthropic": "anthropic_api_key", + "google": "google_api_key", + "x-ai": "xai_api_key", + } + + config_key = key_mapping.get(provider) + if config_key: + return frappe.conf.get(config_key) + + return None + + +def get_provider_from_model(model: str) -> str: + """Determine provider from model name""" + if model.startswith("gpt-"): + return "openai" + elif model.startswith("claude-"): + return "anthropic" + elif model.startswith("gemini-"): + return "google" + elif model.startswith("grok-"): + return "x-ai" + else: + return "openai" # default + + +def set_api_key_for_provider(model: str, api_key: str): + """Set API key in environment for litellm""" + import os + + provider = get_provider_from_model(model) + + key_mapping = { + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GOOGLE_API_KEY", + "x-ai": "XAI_API_KEY", + } + + env_key = key_mapping.get(provider) + if env_key: + os.environ[env_key] = api_key + + +@frappe.whitelist() +def get_ai_models(): + """API endpoint to get available AI models""" + return get_available_models() + + +@frappe.whitelist() +def generate_page_from_prompt(prompt: str, model: str, api_key: str | None = None): + """ + API endpoint to generate page blocks from a prompt + + Args: + prompt: User's page description + model: AI model to use + api_key: Optional API key + + Returns: + dict with blocks array + """ + if not frappe.has_permission("Builder Page", ptype="write"): + frappe.throw(_("You do not have permission to generate pages")) + + if not prompt or not prompt.strip(): + frappe.throw(_("Please provide a prompt describing the page you want to create")) + + if not model: + frappe.throw(_("Please select an AI model")) + + # Generate blocks + blocks = generate_page_blocks(prompt, model, api_key) + + return { + "success": True, + "blocks": blocks, + "message": _("Page generated successfully"), + } + + +@frappe.whitelist() +def test_api_key(model: str, api_key: str): + """Test if an API key is valid""" + try: + import litellm + + set_api_key_for_provider(model, api_key) + + # Make a simple test call + response = litellm.completion( + model=model, + messages=[{"role": "user", "content": "Say 'OK' if you can read this"}], + max_tokens=10, + ) + + return { + "success": True, + "message": _("API key is valid"), + } + + except Exception as e: + return { + "success": False, + "message": str(e), + } diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 80276f733..819e79746 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -8,6 +8,7 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + AIPageGeneratorModal: typeof import('./src/components/AIPageGeneratorModal.vue')['default'] AlertDialog: typeof import('./src/components/AlertDialog.vue')['default'] AnalyticsFilters: typeof import('./src/components/Settings/AnalyticsFilters.vue')['default'] AnalyticsOverview: typeof import('./src/components/Settings/AnalyticsOverview.vue')['default'] diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue new file mode 100644 index 000000000..44b21f2fa --- /dev/null +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -0,0 +1,336 @@ + + + diff --git a/frontend/src/components/BuilderToolbar.vue b/frontend/src/components/BuilderToolbar.vue index 7e60c4821..b7b70d986 100644 --- a/frontend/src/components/BuilderToolbar.vue +++ b/frontend/src/components/BuilderToolbar.vue @@ -104,6 +104,13 @@ v-if="pageStore.savingPage && pageStore.activePage?.is_template"> Saving template + + + @@ -157,7 +164,7 @@ import { BuilderPage } from "@/types/Builder/BuilderPage"; import { getTextContent } from "@/utils/helpers"; import { useDark, useToggle } from "@vueuse/core"; import { Badge, Popover, Tooltip } from "frappe-ui"; -import { computed, defineAsyncComponent, ref } from "vue"; +import { computed, defineAsyncComponent, inject, ref } from "vue"; import { toast } from "vue-sonner"; import MainMenu from "./MainMenu.vue"; import PageOptions from "./PageOptions.vue"; @@ -175,6 +182,17 @@ const pageStore = usePageStore(); const showInfoDialog = ref(false); const showSettingsDialog = ref(false); +// Get the AI generator opener from PageBuilder +const openAIGeneratorFn = inject<(() => void) | undefined>("showAIGenerator", undefined); + +const openAIGenerator = () => { + if (openAIGeneratorFn) { + openAIGeneratorFn(); + } else { + toast.error("AI Generator is not available"); + } +}; + const currentlyViewedByText = computed(() => { const names = builderStore.viewers.map((viewer) => viewer.fullname).map((name) => name.split(" ")[0]); const count = names.length; diff --git a/frontend/src/pages/PageBuilder.vue b/frontend/src/pages/PageBuilder.vue index 6ef4621ef..1dec08aff 100644 --- a/frontend/src/pages/PageBuilder.vue +++ b/frontend/src/pages/PageBuilder.vue @@ -100,10 +100,14 @@ required /> + diff --git a/frontend/src/pages/PageBuilder.vue b/frontend/src/pages/PageBuilder.vue index 1dec08aff..7159cf022 100644 --- a/frontend/src/pages/PageBuilder.vue +++ b/frontend/src/pages/PageBuilder.vue @@ -102,7 +102,8 @@ + @generated="handleGeneratedBlocks" + @streaming="handleStreamingBlocks"> @@ -123,7 +124,7 @@ import usePageStore from "@/stores/pageStore"; import { BuilderPage } from "@/types/Builder/BuilderPage"; import { getUsersInfo } from "@/usersInfo"; import blockController from "@/utils/blockController"; -import { getRootBlockTemplate, isTargetEditable } from "@/utils/helpers"; +import { getBlockInstance, getRootBlockTemplate, isTargetEditable } from "@/utils/helpers"; import { useBuilderEvents } from "@/utils/useBuilderEvents"; import { breakpointsTailwind, @@ -162,16 +163,26 @@ provide("showAIGenerator", () => { const handleGeneratedBlocks = (blocks: any[]) => { if (!blocks || blocks.length === 0) return; - // Replace the page blocks with the generated blocks - pageStore.pageBlocks.splice(0, pageStore.pageBlocks.length); - blocks.forEach((block) => { - pageStore.pageBlocks.push(block); - }); + // Replace the page blocks with the final generated blocks + pageStore.pageBlocks = [getBlockInstance(blocks[0])]; + canvasStore.activeCanvas?.setRootBlock(pageStore.pageBlocks[0] as any, false); // Force a page save pageStore.savePage(); }; +// Handle live streaming blocks (partial, not saved) +const handleStreamingBlocks = (blocks: any[]) => { + if (!blocks || blocks.length === 0) return; + + try { + pageStore.pageBlocks = [getBlockInstance(blocks[0])]; + canvasStore.activeCanvas?.setRootBlock(pageStore.pageBlocks[0] as any, false); + } catch { + // Partial block may still be invalid, skip this frame + } +}; + watch([() => canvasStore.editableBlock, () => pageStore.activePage?.is_standard], () => { builderStore.toggleReadOnlyMode( canvasStore.editingMode === "page" && From ffb1bf4c221393781dd82634cea38d8e392ca507 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 10 Mar 2026 20:38:06 +0530 Subject: [PATCH 003/287] feat(ai-settings): add AI model and API key configuration to Builder Settings --- builder/ai_page_generator.py | 29 +- .../builder_settings/builder_settings.json | 22 +- frontend/components.d.ts | 1 + .../src/components/AIPageGeneratorModal.vue | 269 ++---------------- frontend/src/components/BuilderSettings.vue | 12 +- frontend/src/components/Settings/GlobalAI.vue | 126 ++++++++ frontend/src/types/Builder/BuilderSettings.ts | 53 ++-- 7 files changed, 231 insertions(+), 281 deletions(-) create mode 100644 frontend/src/components/Settings/GlobalAI.vue diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index b05bb050f..de9a8f694 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -339,14 +339,14 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> return blocks except json.JSONDecodeError as e: - frappe.log_error(f"JSON parsing error: {str(e)}\nContent: {content}", "AI Page Generation Error") + frappe.log_error(f"JSON parsing error: {e!s}\nContent: {content}", "AI Page Generation Error") frappe.throw( _("Failed to parse AI response as JSON. The model may have returned invalid output."), title=_("Generation Error"), ) except Exception as e: - frappe.log_error(f"AI generation error: {str(e)}", "AI Page Generation Error") + frappe.log_error(f"AI generation error: {e!s}", "AI Page Generation Error") frappe.throw( _("Failed to generate page: {0}").format(str(e)), title=_("Generation Error"), @@ -410,17 +410,10 @@ def get_ai_models(): @frappe.whitelist() -def generate_page_from_prompt(prompt: str, model: str, api_key: str | None = None): +def generate_page_from_prompt(prompt: str, model: str | None = None, api_key: str | None = None): """ - API endpoint to generate page blocks from a prompt - - Args: - prompt: User's page description - model: AI model to use - api_key: Optional API key - - Returns: - dict with blocks array + API endpoint to generate page blocks from a prompt. + Reads model and API key from Builder Settings if not provided. """ if not frappe.has_permission("Builder Page", ptype="write"): frappe.throw(_("You do not have permission to generate pages")) @@ -428,10 +421,16 @@ def generate_page_from_prompt(prompt: str, model: str, api_key: str | None = Non if not prompt or not prompt.strip(): frappe.throw(_("Please provide a prompt describing the page you want to create")) + # Read from Builder Settings if not provided if not model: - frappe.throw(_("Please select an AI model")) + settings = frappe.get_single("Builder Settings") + model = settings.get("ai_model") + if not api_key: + api_key = settings.get_password("ai_api_key", raise_exception=False) + + if not model: + frappe.throw(_("Please configure an AI model in Settings → AI")) - # Generate blocks blocks = generate_page_blocks(prompt, model, api_key) return { @@ -450,7 +449,7 @@ def test_api_key(model: str, api_key: str): set_api_key_for_provider(model, api_key) # Make a simple test call - response = litellm.completion( + litellm.completion( model=model, messages=[{"role": "user", "content": "Say 'OK' if you can read this"}], max_tokens=10, diff --git a/builder/builder/doctype/builder_settings/builder_settings.json b/builder/builder/doctype/builder_settings/builder_settings.json index d65c28cdf..5c7b9f6c9 100644 --- a/builder/builder/doctype/builder_settings/builder_settings.json +++ b/builder/builder/doctype/builder_settings/builder_settings.json @@ -18,7 +18,10 @@ "home_page", "developer_options_section", "execute_block_scripts_in_editor", - "restrict_click_handlers" + "restrict_click_handlers", + "ai_section", + "ai_model", + "ai_api_key" ], "fields": [ { @@ -106,6 +109,23 @@ "fieldname": "restrict_click_handlers", "fieldtype": "Check", "label": "Restrict Click Handlers" + }, + { + "fieldname": "ai_section", + "fieldtype": "Section Break", + "label": "AI Settings" + }, + { + "description": "AI model to use for page generation (e.g., gpt-4o, claude-sonnet-4-6, gemini-1.5-flash)", + "fieldname": "ai_model", + "fieldtype": "Data", + "label": "AI Model" + }, + { + "description": "API key for the selected AI model provider", + "fieldname": "ai_api_key", + "fieldtype": "Password", + "label": "AI API Key" } ], "hide_toolbar": 1, diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 819e79746..e0e636cda 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -71,6 +71,7 @@ declare module 'vue' { Folder: typeof import('./src/components/Icons/Folder.vue')['default'] FontUploader: typeof import('./src/components/Controls/FontUploader.vue')['default'] GenericControl: typeof import('./src/components/Controls/GenericControl.vue')['default'] + GlobalAI: typeof import('./src/components/Settings/GlobalAI.vue')['default'] GlobalAnalytics: typeof import('./src/components/Settings/GlobalAnalytics.vue')['default'] GlobalCode: typeof import('./src/components/Settings/GlobalCode.vue')['default'] GlobalDeveloper: typeof import('./src/components/Settings/GlobalDeveloper.vue')['default'] diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index 4e2cb223f..23371737b 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -2,144 +2,40 @@ @@ -148,21 +44,11 @@ diff --git a/frontend/src/types/Builder/BuilderSettings.ts b/frontend/src/types/Builder/BuilderSettings.ts index d692dbafd..ce565f4b6 100644 --- a/frontend/src/types/Builder/BuilderSettings.ts +++ b/frontend/src/types/Builder/BuilderSettings.ts @@ -1,37 +1,40 @@ - -export interface BuilderSettings{ - creation: string - name: string - modified: string - owner: string - modified_by: string - docstatus: 0 | 1 | 2 - parent?: string - parentfield?: string - parenttype?: string - idx?: number +export interface BuilderSettings { + creation: string; + name: string; + modified: string; + owner: string; + modified_by: string; + docstatus: 0 | 1 | 2; + parent?: string; + parentfield?: string; + parenttype?: string; + idx?: number; /** Head HTML : Code - This will be appended at the end of the <head> */ - head_html?: string + head_html?: string; /** Body HTML : Code - This will be appended at the end of the <body> */ - body_html?: string + body_html?: string; /** Script : Code - Global script that will be loaded with every page built with Frappe Builder */ - script?: string + script?: string; /** Script Public URL : Read Only */ - script_public_url?: string + script_public_url?: string; /** Style : Code - Global style that will be loaded with every page built with Frappe Builder */ - style?: string + style?: string; /** Style Public URL : Read Only */ - style_public_url?: string + style_public_url?: string; /** Favicon : Attach Image - An icon file with .ico extension. Should be 16 x 16 px.
You can generate using favicon-generator.org */ - favicon?: string + favicon?: string; /** Auto convert images to WebP : Check - All the images uploaded to Canvas will be auto converted to WebP for better page performance. */ - auto_convert_images_to_webp?: 0 | 1 + auto_convert_images_to_webp?: 0 | 1; /** Default Language : Data - Default language code for HTML lang attribute (e.g., en, es, fr, de) */ - default_language?: string + default_language?: string; /** Home Page : Data */ - home_page?: string, + home_page?: string; /** Execute Block Scripts in Editor : Check - Tries to best emulate how blocks will look like on the live site by executing Block Scripts. */ - execute_block_scripts_in_editor?: "Don't Execute" | "Restricted" | "Unrestricted" + execute_block_scripts_in_editor?: "Don't Execute" | "Restricted" | "Unrestricted"; /** Prevent Click Emulation : Check - Prevents click events from being emulated in the editor for blocks with Block Client Scripts. */ - restrict_click_handlers?: 0 | 1 -} \ No newline at end of file + restrict_click_handlers?: 0 | 1; + /** AI Model : Data - AI model to use for page generation */ + ai_model?: string; + /** AI API Key : Password - API key for the selected AI model provider */ + ai_api_key?: string; +} From 12957cee5033edab8088d69ac94f28ae75a43f2b Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 08:02:30 +0530 Subject: [PATCH 004/287] feat(ai-page-generator): add floating progress indicator for page generation --- builder/ai_page_generator.py | 35 ++++--------------- .../src/components/AIPageGeneratorModal.vue | 27 ++++++++++++++ 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index de9a8f694..853a3cb67 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -63,23 +63,13 @@ def get_system_prompt(): # Block Structure Rules: -1. **Root Block**: Always wrap everything in a root body element: -```json -{ - "element": "body", - "blockId": "root", - "children": [...], - "baseStyles": {} -} -``` - 2. **Common Elements**: Use semantic HTML elements: - Container: "div" with display: flex/grid - Text: "h1", "h2", "h3", "p", "span" - Buttons: "button" or "a" - Images: "img" with src attribute - Sections: "section", "header", "footer", "nav" - - Font: Choose from Google Fonts and specify in styles + - Font: Choose creative/relevant fonts from Google Fonts and specify in baseStyles (e.g., "fontFamily": "Roboto") 3. **Styling (baseStyles)**: Use CSS-in-JS object format: ```json @@ -124,6 +114,7 @@ def get_system_prompt(): ```json { "element": "h1", + "blockName": "hero-title", "innerText": "Welcome to Our Site" } ``` @@ -157,6 +148,7 @@ def get_system_prompt(): "justifyContent": "center", "minHeight": "80vh", "padding": "4rem 2rem", + "width": "100%", "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", "color": "#ffffff" }, @@ -187,6 +179,7 @@ def get_system_prompt(): "justifyContent": "space-between", "alignItems": "center", "padding": "1rem 2rem", + "width": "100%", "backgroundColor": "#ffffff", "boxShadow": "0 2px 4px rgba(0,0,0,0.1)" }, @@ -311,24 +304,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> ) # Wrap the generated section in a proper Builder root block - blocks = [ - { - "element": "div", - "originalElement": "body", - "blockId": "root", - "children": [section], - "baseStyles": { - "display": "flex", - "flexWrap": "wrap", - "flexShrink": "0", - "flexDirection": "column", - "alignItems": "center", - }, - "attributes": {}, - "mobileStyles": {}, - "tabletStyles": {}, - } - ] + blocks = [section] frappe.publish_realtime( "ai_generation_progress", @@ -346,6 +322,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> ) except Exception as e: + print(f"Error during AI generation: {e!s}") frappe.log_error(f"AI generation error: {e!s}", "AI Page Generation Error") frappe.throw( _("Failed to generate page: {0}").format(str(e)), diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index 23371737b..d4f8aacee 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -40,6 +40,21 @@
+ + + + +
+
+
+ + {{ progressMessage || "Generating page..." }} + +
+
+
+
+ + From 2c48f026a9ff4d32509c1a32a994aa44b044e3bf Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 09:46:04 +0530 Subject: [PATCH 005/287] fix: Set blockName in example structure for better response --- builder/ai_page_generator.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index 853a3cb67..5c1237561 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -132,7 +132,7 @@ def get_system_prompt(): 2. **Color Schemes**: Use harmonious color palettes 3. **Typography**: Use appropriate font sizes and weights 4. **Spacing**: Apply consistent padding and margins -5. **Responsive**: Ensure mobile-first responsive design +5. **Responsive**: Ensure mobile-first responsive design, set relative units (%, rem) for widths. 6. **Accessibility**: Include proper semantic HTML and alt texts # Common Layouts: @@ -141,6 +141,7 @@ def get_system_prompt(): ```json { "element": "section", + "blockName": "hero-section", "baseStyles": { "display": "flex", "flexDirection": "column", @@ -160,6 +161,7 @@ def get_system_prompt(): ```json { "element": "div", + "blockName": "product-grid", "baseStyles": { "display": "grid", "gridTemplateColumns": "repeat(auto-fit, minmax(300px, 1fr))", @@ -174,6 +176,7 @@ def get_system_prompt(): ```json { "element": "nav", + "blockName": "main-nav", "baseStyles": { "display": "flex", "justifyContent": "space-between", @@ -192,14 +195,15 @@ def get_system_prompt(): Return ONLY a JSON array with no markdown code blocks, no explanations, no additional text. Start directly with `[` and end with `]`. Example output: -[{"element":"body","blockId":"root","children":[{"element":"section","baseStyles":{"display":"flex"},"children":[...]}],"baseStyles":{}}] +[{"element":"section","baseStyles":{"display":"flex"},"children":[...],attributes:{},blockName:"hero-section"}, {"element":"div","baseStyles":{"display":"grid"},"children":[...]}, ...] Remember: - Generate complete, production-ready pages - Use modern design principles - Ensure responsive design - Return ONLY valid JSON -- No markdown, no explanations, just JSON""" +- No markdown, no explanations, just JSON +- Make sure widths are set to 100% for responsiveness""" def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> list[dict[str, Any]]: From 87a9fd58f350f1ee557d79567ffe0838a0ef4e7c Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 09:49:56 +0530 Subject: [PATCH 006/287] feat(ai-page-generator): Don't wait for generation request to end Just accept request and handle streaming via socket --- builder/ai_page_generator.py | 113 +++++++++------- .../src/components/AIPageGeneratorModal.vue | 121 ++++++++++-------- frontend/src/pages/PageBuilder.vue | 21 ++- 3 files changed, 153 insertions(+), 102 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index 5c1237561..4fdb3ada4 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -4,7 +4,6 @@ """ import json -from typing import Any import frappe from frappe import _ @@ -206,48 +205,50 @@ def get_system_prompt(): - Make sure widths are set to 100% for responsiveness""" -def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> list[dict[str, Any]]: +def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, user: str | None = None): """ Generate page blocks from a prompt using the specified AI model with streaming. - Streams response chunks to the frontend via realtime events so users - can see the generation happening in real time. + Runs as a background job. Streams chunks and publishes final blocks via realtime. Args: prompt: User's description of the page they want model: Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6') api_key: Optional API key (if not configured in site config) - - Returns: - List of block dictionaries + user: The user to publish realtime events to """ + user = user or frappe.session.user content = "" try: try: import litellm except ImportError: - frappe.throw( - _("litellm library is not installed. Please install it using: pip install litellm"), - title=_("Missing Dependency"), + frappe.publish_realtime( + "ai_generation_error", + {"message": "litellm library is not installed. Please install it using: pip install litellm"}, + user=user, ) + return if not api_key: api_key = get_api_key_for_model(model) if not api_key: - frappe.throw( - _( - "API key not configured for model: {0}. Please configure it in site config or provide it in the request." - ).format(model), - title=_("API Key Missing"), + frappe.publish_realtime( + "ai_generation_error", + { + "message": f"API key not configured for model: {model}. Please configure it in Settings \u2192 AI." + }, + user=user, ) + return set_api_key_for_provider(model, api_key) frappe.publish_realtime( "ai_generation_progress", {"status": "preparing", "message": "Preparing AI request..."}, - user=frappe.session.user, + user=user, ) messages = [ @@ -258,7 +259,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> frappe.publish_realtime( "ai_generation_progress", {"status": "generating", "message": f"Generating page with {model}..."}, - user=frappe.session.user, + user=user, ) # Stream the response so the frontend can show progressive output @@ -266,7 +267,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> model=model, messages=messages, temperature=0.7, - max_tokens=8000, + max_tokens=12000, stream=True, ) @@ -278,7 +279,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> frappe.publish_realtime( "ai_generation_stream", {"chunk": delta, "accumulated": content}, - user=frappe.session.user, + user=user, ) content = content.strip() @@ -286,7 +287,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> frappe.publish_realtime( "ai_generation_progress", {"status": "parsing", "message": "Parsing generated content..."}, - user=frappe.session.user, + user=user, ) # Remove markdown code blocks if present @@ -295,42 +296,49 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None) -> if content.endswith("```"): content = content[:-3].strip() - section = json.loads(content) - - # Normalize: if the model returned an array, take the first element - if isinstance(section, list): - section = section[0] if section else {} - - if not isinstance(section, dict): - frappe.throw( - _("AI response is not a valid block object"), - title=_("Invalid Response"), + parsed = json.loads(content) + + # Normalize: ensure we have a list of section blocks + if isinstance(parsed, dict): + blocks = [parsed] + elif isinstance(parsed, list): + blocks = [b for b in parsed if isinstance(b, dict)] + else: + frappe.publish_realtime( + "ai_generation_error", + {"message": "AI response is not a valid block object"}, + user=user, ) + return - # Wrap the generated section in a proper Builder root block - blocks = [section] + if not blocks: + frappe.publish_realtime( + "ai_generation_error", + {"message": "AI response contained no valid blocks"}, + user=user, + ) + return frappe.publish_realtime( - "ai_generation_progress", - {"status": "complete", "message": "Page generated successfully!"}, - user=frappe.session.user, + "ai_generation_complete", + {"blocks": blocks}, + user=user, ) - return blocks - except json.JSONDecodeError as e: frappe.log_error(f"JSON parsing error: {e!s}\nContent: {content}", "AI Page Generation Error") - frappe.throw( - _("Failed to parse AI response as JSON. The model may have returned invalid output."), - title=_("Generation Error"), + frappe.publish_realtime( + "ai_generation_error", + {"message": "Failed to parse AI response as JSON. The model may have returned invalid output."}, + user=user, ) except Exception as e: - print(f"Error during AI generation: {e!s}") frappe.log_error(f"AI generation error: {e!s}", "AI Page Generation Error") - frappe.throw( - _("Failed to generate page: {0}").format(str(e)), - title=_("Generation Error"), + frappe.publish_realtime( + "ai_generation_error", + {"message": f"Failed to generate page: {e!s}"}, + user=user, ) @@ -412,12 +420,21 @@ def generate_page_from_prompt(prompt: str, model: str | None = None, api_key: st if not model: frappe.throw(_("Please configure an AI model in Settings → AI")) - blocks = generate_page_blocks(prompt, model, api_key) + user = frappe.session.user + + frappe.enqueue( + generate_page_blocks, + prompt=prompt, + model=model, + api_key=api_key, + user=user, + queue="default", + is_async=True, + ) return { - "success": True, - "blocks": blocks, - "message": _("Page generated successfully"), + "status": "started", + "message": _("Page generation started"), } diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index d4f8aacee..5045e7049 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -155,26 +155,18 @@ const generatePage = async () => { showDialog.value = false; try { - const result = await createResource({ + // Fire and forget — results come through realtime events + await createResource({ url: "builder.ai_page_generator.generate_page_from_prompt", makeParams: () => ({ prompt: prompt.value, }), }).submit(); - - if (result.success && result.blocks) { - emit("generated", result.blocks); - prompt.value = ""; - } else { - showDialog.value = true; - errorMessage.value = "Failed to generate page. Please try again."; - } } catch (error: any) { - showDialog.value = true; - errorMessage.value = error.message || "An error occurred while generating the page"; - } finally { generating.value = false; progressMessage.value = ""; + showDialog.value = true; + errorMessage.value = error.message || "An error occurred while generating the page"; } }; @@ -187,53 +179,78 @@ watch(showDialog, (newValue) => { }); // Setup socket connection for progress updates -onMounted(() => { - builderStore.realtime.on("ai_generation_progress", (data: any) => { - if (data.message) { - progressMessage.value = data.message; - } - }); - builderStore.realtime.on("ai_generation_stream", (data: any) => { - if (data.chunk) { - streamingContent.value += data.chunk; - // Try to repair partial JSON and render live - const repaired = repairPartialJSON(streamingContent.value); - if (repaired) { - try { - let section = JSON.parse(repaired); - if (Array.isArray(section)) section = section[0]; - if (section && typeof section === "object" && section.element) { - const wrapped = [ - { - element: "div", - originalElement: "body", - blockId: "root", - children: [section], - baseStyles: { - display: "flex", - flexWrap: "wrap", - flexShrink: "0", - flexDirection: "column", - alignItems: "center", - }, - attributes: {}, - mobileStyles: {}, - tabletStyles: {}, +const onProgress = (data: any) => { + if (data.message) { + progressMessage.value = data.message; + } +}; + +const onStream = (data: any) => { + if (data.chunk) { + streamingContent.value += data.chunk; + // Try to repair partial JSON and render live + const repaired = repairPartialJSON(streamingContent.value); + if (repaired) { + try { + let parsed = JSON.parse(repaired); + let sections = Array.isArray(parsed) ? parsed : [parsed]; + sections = sections.filter((s: any) => s && typeof s === "object" && s.element); + if (sections.length > 0) { + const wrapped = [ + { + element: "div", + originalElement: "body", + blockId: "root", + children: sections, + baseStyles: { + display: "flex", + flexWrap: "wrap", + flexShrink: "0", + flexDirection: "column", + alignItems: "center", }, - ]; - emit("streaming", wrapped); - } - } catch { - // Still not valid enough, wait for more chunks + attributes: {}, + mobileStyles: {}, + tabletStyles: {}, + }, + ]; + emit("streaming", wrapped); } + } catch { + // Still not valid enough, wait for more chunks } } - }); + } +}; + +const onComplete = (data: any) => { + generating.value = false; + progressMessage.value = ""; + if (data.blocks) { + emit("generated", data.blocks); + prompt.value = ""; + } +}; + +const onError = (data: any) => { + generating.value = false; + progressMessage.value = ""; + showDialog.value = true; + errorMessage.value = data.message || "An error occurred while generating the page"; +}; + +onMounted(() => { + builderStore.realtime.on("ai_generation_progress", onProgress); + builderStore.realtime.on("ai_generation_stream", onStream); + builderStore.realtime.on("ai_generation_complete", onComplete); + builderStore.realtime.on("ai_generation_error", onError); }); onUnmounted(() => { - builderStore.realtime.off("ai_generation_progress", () => {}); - builderStore.realtime.off("ai_generation_stream", () => {}); + builderStore.realtime.off("ai_generation_progress", onProgress); + builderStore.realtime.off("ai_generation_stream", onStream); + builderStore.realtime.off("ai_generation_complete", onComplete); + builderStore.realtime.off("ai_generation_error", onError); }); diff --git a/frontend/src/pages/PageBuilder.vue b/frontend/src/pages/PageBuilder.vue index 7159cf022..8fd54f3bc 100644 --- a/frontend/src/pages/PageBuilder.vue +++ b/frontend/src/pages/PageBuilder.vue @@ -163,8 +163,25 @@ provide("showAIGenerator", () => { const handleGeneratedBlocks = (blocks: any[]) => { if (!blocks || blocks.length === 0) return; - // Replace the page blocks with the final generated blocks - pageStore.pageBlocks = [getBlockInstance(blocks[0])]; + // Wrap all generated sections in a root block + const root = { + element: "div", + originalElement: "body", + blockId: "root", + children: blocks, + baseStyles: { + display: "flex", + flexWrap: "wrap", + flexShrink: "0", + flexDirection: "column", + alignItems: "center", + }, + attributes: {}, + mobileStyles: {}, + tabletStyles: {}, + }; + + pageStore.pageBlocks = [getBlockInstance(root)]; canvasStore.activeCanvas?.setRootBlock(pageStore.pageBlocks[0] as any, false); // Force a page save From 029ea254bf7cfc1323ed4b7548f335eb719ab730 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 11:52:12 +0530 Subject: [PATCH 007/287] fix: replace textarea with Textarea --- frontend/src/components/AIPageGeneratorModal.vue | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index 5045e7049..1633354be 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -7,13 +7,13 @@ }"> @@ -61,7 +59,7 @@ import Dialog from "@/components/Controls/Dialog.vue"; import { builderSettings } from "@/data/builderSettings"; import useBuilderStore from "@/stores/builderStore"; -import { createResource } from "frappe-ui"; +import { createResource, Textarea } from "frappe-ui"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; const props = defineProps<{ From 4fc8222ee2b4f5026f3d4c53e214a412cd4a8600 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 12:24:05 +0530 Subject: [PATCH 008/287] feat: update AI model options to include GPT-5 series in page generation --- builder/ai_page_generator.py | 16 +++++++++------- .../builder_settings/builder_settings.json | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index 4fdb3ada4..b595ce4e8 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -15,12 +15,14 @@ def get_available_models(): { "provider": "openai", "models": [ - {"name": "chatgpt-4o-latest", "label": "GPT-4o Latest", "max_tokens": 128000}, - {"name": "gpt-4o", "label": "GPT-4o", "max_tokens": 128000}, - {"name": "gpt-4o-mini", "label": "GPT-4o Mini", "max_tokens": 128000}, - {"name": "o1", "label": "O1 (Reasoning)", "max_tokens": 100000}, - {"name": "o1-mini", "label": "O1 Mini (Reasoning)", "max_tokens": 65000}, - {"name": "gpt-4-turbo", "label": "GPT-4 Turbo", "max_tokens": 128000}, + {"name": "gpt-5.4", "label": "GPT-5.4 (Flagship)", "max_tokens": 1050000}, + {"name": "gpt-5", "label": "GPT-5 (Reasoning)", "max_tokens": 400000}, + {"name": "gpt-5-mini", "label": "GPT-5 Mini (Fast)", "max_tokens": 400000}, + {"name": "gpt-5-nano", "label": "GPT-5 Nano (Cheapest)", "max_tokens": 400000}, + {"name": "gpt-4.1", "label": "GPT-4.1", "max_tokens": 1047576}, + {"name": "gpt-4.1-mini", "label": "GPT-4.1 Mini", "max_tokens": 1047576}, + {"name": "o3", "label": "o3 (Reasoning)", "max_tokens": 200000}, + {"name": "o4-mini", "label": "o4-mini (Fast Reasoning)", "max_tokens": 200000}, ], }, { @@ -213,7 +215,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us Args: prompt: User's description of the page they want - model: Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6') + model: Model identifier (e.g., 'gpt-5.4', 'claude-sonnet-4-6') api_key: Optional API key (if not configured in site config) user: The user to publish realtime events to """ diff --git a/builder/builder/doctype/builder_settings/builder_settings.json b/builder/builder/doctype/builder_settings/builder_settings.json index 5c7b9f6c9..22d95e0f3 100644 --- a/builder/builder/doctype/builder_settings/builder_settings.json +++ b/builder/builder/doctype/builder_settings/builder_settings.json @@ -116,7 +116,7 @@ "label": "AI Settings" }, { - "description": "AI model to use for page generation (e.g., gpt-4o, claude-sonnet-4-6, gemini-1.5-flash)", + "description": "AI model to use for page generation (e.g., gpt-5.4, claude-sonnet-4-6, gemini-1.5-flash)", "fieldname": "ai_model", "fieldtype": "Data", "label": "AI Model" From a5b5111e38e211f534d5553e64fb617bdfa9a8df Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 12:24:17 +0530 Subject: [PATCH 009/287] fix(ai-page-generator): enable drop_params for GPT-5+ models to handle unsupported kwargs --- builder/ai_page_generator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index b595ce4e8..a64f7b6c3 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -265,6 +265,9 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us ) # Stream the response so the frontend can show progressive output + # GPT-5+ models only support temperature=1; use drop_params to + # let litellm silently strip unsupported kwargs for any model. + litellm.drop_params = True response = litellm.completion( model=model, messages=messages, From d425cdff650b061991689c18015e6c09fdb67ea9 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 14:25:47 +0530 Subject: [PATCH 010/287] feat: add page_id parameter to generate_page_blocks and AIPageGeneratorModal for scoped realtime events --- builder/ai_page_generator.py | 35 ++++++++++++------- .../src/components/AIPageGeneratorModal.vue | 6 ++++ frontend/src/pages/PageBuilder.vue | 1 + 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index a64f7b6c3..c46c35f46 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -207,7 +207,13 @@ def get_system_prompt(): - Make sure widths are set to 100% for responsiveness""" -def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, user: str | None = None): +def generate_page_blocks( + prompt: str, + model: str, + api_key: str | None = None, + user: str | None = None, + page_id: str | None = None, +): """ Generate page blocks from a prompt using the specified AI model with streaming. @@ -218,6 +224,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us model: Model identifier (e.g., 'gpt-5.4', 'claude-sonnet-4-6') api_key: Optional API key (if not configured in site config) user: The user to publish realtime events to + page_id: Builder Page ID to scope realtime events to """ user = user or frappe.session.user content = "" @@ -227,7 +234,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us except ImportError: frappe.publish_realtime( "ai_generation_error", - {"message": "litellm library is not installed. Please install it using: pip install litellm"}, + {"page_id": page_id, "message": "litellm library is not installed. Please install it using: pip install litellm"}, user=user, ) return @@ -239,7 +246,8 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us frappe.publish_realtime( "ai_generation_error", { - "message": f"API key not configured for model: {model}. Please configure it in Settings \u2192 AI." + "page_id": page_id, + "message": f"API key not configured for model: {model}. Please configure it in Settings \u2192 AI.", }, user=user, ) @@ -249,7 +257,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us frappe.publish_realtime( "ai_generation_progress", - {"status": "preparing", "message": "Preparing AI request..."}, + {"page_id": page_id, "status": "preparing", "message": "Preparing AI request..."}, user=user, ) @@ -260,7 +268,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us frappe.publish_realtime( "ai_generation_progress", - {"status": "generating", "message": f"Generating page with {model}..."}, + {"page_id": page_id, "status": "generating", "message": f"Generating page with {model}..."}, user=user, ) @@ -283,7 +291,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us content += delta frappe.publish_realtime( "ai_generation_stream", - {"chunk": delta, "accumulated": content}, + {"page_id": page_id, "chunk": delta, "accumulated": content}, user=user, ) @@ -291,7 +299,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us frappe.publish_realtime( "ai_generation_progress", - {"status": "parsing", "message": "Parsing generated content..."}, + {"page_id": page_id, "status": "parsing", "message": "Parsing generated content..."}, user=user, ) @@ -311,7 +319,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us else: frappe.publish_realtime( "ai_generation_error", - {"message": "AI response is not a valid block object"}, + {"page_id": page_id, "message": "AI response is not a valid block object"}, user=user, ) return @@ -319,14 +327,14 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us if not blocks: frappe.publish_realtime( "ai_generation_error", - {"message": "AI response contained no valid blocks"}, + {"page_id": page_id, "message": "AI response contained no valid blocks"}, user=user, ) return frappe.publish_realtime( "ai_generation_complete", - {"blocks": blocks}, + {"page_id": page_id, "blocks": blocks}, user=user, ) @@ -334,7 +342,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us frappe.log_error(f"JSON parsing error: {e!s}\nContent: {content}", "AI Page Generation Error") frappe.publish_realtime( "ai_generation_error", - {"message": "Failed to parse AI response as JSON. The model may have returned invalid output."}, + {"page_id": page_id, "message": "Failed to parse AI response as JSON. The model may have returned invalid output."}, user=user, ) @@ -342,7 +350,7 @@ def generate_page_blocks(prompt: str, model: str, api_key: str | None = None, us frappe.log_error(f"AI generation error: {e!s}", "AI Page Generation Error") frappe.publish_realtime( "ai_generation_error", - {"message": f"Failed to generate page: {e!s}"}, + {"page_id": page_id, "message": f"Failed to generate page: {e!s}"}, user=user, ) @@ -404,7 +412,7 @@ def get_ai_models(): @frappe.whitelist() -def generate_page_from_prompt(prompt: str, model: str | None = None, api_key: str | None = None): +def generate_page_from_prompt(prompt: str, page_id: str | None = None, model: str | None = None, api_key: str | None = None): """ API endpoint to generate page blocks from a prompt. Reads model and API key from Builder Settings if not provided. @@ -433,6 +441,7 @@ def generate_page_from_prompt(prompt: str, model: str | None = None, api_key: st model=model, api_key=api_key, user=user, + page_id=page_id, queue="default", is_async=True, ) diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index 1633354be..dd20dd338 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -64,6 +64,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from "vue"; const props = defineProps<{ modelValue: boolean; + pageId: string; }>(); const emit = defineEmits<{ @@ -158,6 +159,7 @@ const generatePage = async () => { url: "builder.ai_page_generator.generate_page_from_prompt", makeParams: () => ({ prompt: prompt.value, + page_id: props.pageId, }), }).submit(); } catch (error: any) { @@ -178,12 +180,14 @@ watch(showDialog, (newValue) => { // Setup socket connection for progress updates const onProgress = (data: any) => { + if (data.page_id && data.page_id !== props.pageId) return; if (data.message) { progressMessage.value = data.message; } }; const onStream = (data: any) => { + if (data.page_id && data.page_id !== props.pageId) return; if (data.chunk) { streamingContent.value += data.chunk; // Try to repair partial JSON and render live @@ -222,6 +226,7 @@ const onStream = (data: any) => { }; const onComplete = (data: any) => { + if (data.page_id && data.page_id !== props.pageId) return; generating.value = false; progressMessage.value = ""; if (data.blocks) { @@ -231,6 +236,7 @@ const onComplete = (data: any) => { }; const onError = (data: any) => { + if (data.page_id && data.page_id !== props.pageId) return; generating.value = false; progressMessage.value = ""; showDialog.value = true; diff --git a/frontend/src/pages/PageBuilder.vue b/frontend/src/pages/PageBuilder.vue index 8fd54f3bc..9cacc772d 100644 --- a/frontend/src/pages/PageBuilder.vue +++ b/frontend/src/pages/PageBuilder.vue @@ -102,6 +102,7 @@ From 0f337d9425d6744bfa14bd176743bdd2709b4510 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 14:26:16 +0530 Subject: [PATCH 011/287] feat(ai-page-generator): implement section modification functionality with AI and update UI for modify mode --- builder/ai_page_generator.py | 319 +++++++++++++++++- .../src/components/AIPageGeneratorModal.vue | 131 ++++++- frontend/src/components/BlockContextMenu.vue | 14 +- frontend/src/pages/PageBuilder.vue | 90 ++++- 4 files changed, 536 insertions(+), 18 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index c46c35f46..78d4c83ba 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -234,7 +234,10 @@ def generate_page_blocks( except ImportError: frappe.publish_realtime( "ai_generation_error", - {"page_id": page_id, "message": "litellm library is not installed. Please install it using: pip install litellm"}, + { + "page_id": page_id, + "message": "litellm library is not installed. Please install it using: pip install litellm", + }, user=user, ) return @@ -342,7 +345,10 @@ def generate_page_blocks( frappe.log_error(f"JSON parsing error: {e!s}\nContent: {content}", "AI Page Generation Error") frappe.publish_realtime( "ai_generation_error", - {"page_id": page_id, "message": "Failed to parse AI response as JSON. The model may have returned invalid output."}, + { + "page_id": page_id, + "message": "Failed to parse AI response as JSON. The model may have returned invalid output.", + }, user=user, ) @@ -405,6 +411,254 @@ def set_api_key_for_provider(model: str, api_key: str): os.environ[env_key] = api_key +def get_modify_system_prompt(): + """Return the system prompt for modifying an existing section""" + return """You are an expert web designer and developer specializing in modifying web page sections using the Frappe Builder block system. + +Your task is to modify an existing block structure based on user instructions. You will receive the current JSON block structure and an instruction describing the desired changes. + +You must return ONLY a valid JSON array of blocks (the modified version), with no additional text, markdown formatting, or explanations. + +# Block Structure Rules: + +1. **Common Elements**: Use semantic HTML elements: + - Container: "div" with display: flex/grid + - Text: "h1", "h2", "h3", "p", "span" + - Buttons: "button" or "a" + - Images: "img" with src attribute + - Sections: "section", "header", "footer", "nav" + - Font: Choose creative/relevant fonts from Google Fonts and specify in baseStyles (e.g., "fontFamily": "Roboto") + +2. **Styling (baseStyles)**: Use CSS-in-JS object format: +```json +{ + "baseStyles": { + "display": "flex", + "flexDirection": "column", + "padding": "2rem", + "backgroundColor": "#ffffff", + "fontSize": "16px", + "fontWeight": "600" + } +} +``` + +3. **Responsive Styles**: Add mobile/tablet overrides: +```json +{ + "mobileStyles": { + "fontSize": "14px", + "padding": "1rem" + }, + "tabletStyles": { + "fontSize": "15px" + } +} +``` + +4. **Attributes**: Add HTML attributes: +```json +{ + "attributes": { + "src": "/path/to/image.jpg", + "alt": "Image description", + "href": "https://example.com", + "target": "_blank" + } +} +``` + +5. **Text Content**: Use innerText or innerHTML: +```json +{ + "element": "h1", + "blockName": "hero-title", + "innerText": "Welcome to Our Site" +} +``` + +6. **Classes**: Add CSS classes if needed: +```json +{ + "classes": ["hero-section", "text-center"] +} +``` + +# Important Modification Guidelines: + +1. **Preserve Structure**: Keep existing block structure where the user hasn't asked for changes. +2. **Maintain blockId**: Preserve existing blockId values to minimize DOM churn. +3. **Surgical Changes**: Only modify what the user asks for — don't redesign the entire section unless requested. +4. **Responsive**: Ensure mobile-first responsive design, set relative units (%, rem) for widths. + +# CRITICAL: Output Format + +Return ONLY a JSON array with no markdown code blocks, no explanations, no additional text. Start directly with `[` and end with `]`. + +The output must be the complete modified block structure (not a diff or patch). + +Remember: +- Return the full modified block structure +- Preserve existing structure where not explicitly asked to change +- Return ONLY valid JSON +- No markdown, no explanations, just JSON""" + + +def modify_section_blocks( + prompt: str, + block_context: str, + model: str, + api_key: str | None = None, + user: str | None = None, + page_id: str | None = None, +): + """ + Modify existing section blocks based on a prompt using the specified AI model with streaming. + + Runs as a background job. Streams chunks and publishes final blocks via realtime. + + Args: + prompt: User's description of the modification they want + block_context: JSON string of the existing block structure to modify + model: Model identifier (e.g., 'gpt-5.4', 'claude-sonnet-4-6') + api_key: Optional API key (if not configured in site config) + user: The user to publish realtime events to + page_id: Builder Page ID to scope realtime events to + """ + user = user or frappe.session.user + content = "" + try: + try: + import litellm + except ImportError: + frappe.publish_realtime( + "ai_modify_error", + { + "page_id": page_id, + "message": "litellm library is not installed. Please install it using: pip install litellm", + }, + user=user, + ) + return + + if not api_key: + api_key = get_api_key_for_model(model) + + if not api_key: + frappe.publish_realtime( + "ai_modify_error", + { + "page_id": page_id, + "message": f"API key not configured for model: {model}. Please configure it in Settings → AI.", + }, + user=user, + ) + return + + set_api_key_for_provider(model, api_key) + + frappe.publish_realtime( + "ai_modify_progress", + {"page_id": page_id, "status": "preparing", "message": "Preparing AI request..."}, + user=user, + ) + + user_message = f"Here is the current section structure:\n```json\n{block_context}\n```\n\nModify it according to: {prompt}" + + messages = [ + {"role": "system", "content": get_modify_system_prompt()}, + {"role": "user", "content": user_message}, + ] + + frappe.publish_realtime( + "ai_modify_progress", + {"page_id": page_id, "status": "generating", "message": f"Modifying section with {model}..."}, + user=user, + ) + + litellm.drop_params = True + response = litellm.completion( + model=model, + messages=messages, + temperature=0.7, + max_tokens=12000, + stream=True, + ) + + content = "" + for chunk in response: + delta = chunk.choices[0].delta.content + if delta: + content += delta + frappe.publish_realtime( + "ai_modify_stream", + {"page_id": page_id, "chunk": delta, "accumulated": content}, + user=user, + ) + + content = content.strip() + + frappe.publish_realtime( + "ai_modify_progress", + {"page_id": page_id, "status": "parsing", "message": "Parsing modified content..."}, + user=user, + ) + + # Remove markdown code blocks if present + if content.startswith("```"): + content = content.split("\n", 1)[1] if "\n" in content else content[3:] + if content.endswith("```"): + content = content[:-3].strip() + + parsed = json.loads(content) + + # Normalize: ensure we have a list of section blocks + if isinstance(parsed, dict): + blocks = [parsed] + elif isinstance(parsed, list): + blocks = [b for b in parsed if isinstance(b, dict)] + else: + frappe.publish_realtime( + "ai_modify_error", + {"page_id": page_id, "message": "AI response is not a valid block object"}, + user=user, + ) + return + + if not blocks: + frappe.publish_realtime( + "ai_modify_error", + {"page_id": page_id, "message": "AI response contained no valid blocks"}, + user=user, + ) + return + + frappe.publish_realtime( + "ai_modify_complete", + {"page_id": page_id, "blocks": blocks}, + user=user, + ) + + except json.JSONDecodeError as e: + frappe.log_error(f"JSON parsing error: {e!s}\nContent: {content}", "AI Section Modify Error") + frappe.publish_realtime( + "ai_modify_error", + { + "page_id": page_id, + "message": "Failed to parse AI response as JSON. The model may have returned invalid output.", + }, + user=user, + ) + + except Exception as e: + frappe.log_error(f"AI modify error: {e!s}", "AI Section Modify Error") + frappe.publish_realtime( + "ai_modify_error", + {"page_id": page_id, "message": f"Failed to modify section: {e!s}"}, + user=user, + ) + + @frappe.whitelist() def get_ai_models(): """API endpoint to get available AI models""" @@ -412,7 +666,66 @@ def get_ai_models(): @frappe.whitelist() -def generate_page_from_prompt(prompt: str, page_id: str | None = None, model: str | None = None, api_key: str | None = None): +def modify_section_from_prompt( + prompt: str, + block_context: str, + page_id: str | None = None, + model: str | None = None, + api_key: str | None = None, +): + """ + API endpoint to modify existing section blocks from a prompt. + Reads model and API key from Builder Settings if not provided. + """ + if not frappe.has_permission("Builder Page", ptype="write"): + frappe.throw(_("You do not have permission to modify pages")) + + if not prompt or not prompt.strip(): + frappe.throw(_("Please provide a prompt describing the modification")) + + if not block_context or not block_context.strip(): + frappe.throw(_("Block context is required for modification")) + + # Validate block_context is valid JSON + try: + json.loads(block_context) + except json.JSONDecodeError: + frappe.throw(_("Invalid block context JSON")) + + # Read from Builder Settings if not provided + if not model: + settings = frappe.get_single("Builder Settings") + model = settings.get("ai_model") + if not api_key: + api_key = settings.get_password("ai_api_key", raise_exception=False) + + if not model: + frappe.throw(_("Please configure an AI model in Settings → AI")) + + user = frappe.session.user + + frappe.enqueue( + modify_section_blocks, + prompt=prompt, + block_context=block_context, + model=model, + api_key=api_key, + user=user, + page_id=page_id, + queue="default", + is_async=True, + ) + + return { + "status": "started", + "message": _("Section modification started"), + } + + +@frappe.whitelist() +def generate_page_from_prompt( + prompt: str, page_id: str | None = None, model: str | None = None, api_key: str | None = None +): """ API endpoint to generate page blocks from a prompt. Reads model and API key from Builder Settings if not provided. diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index dd20dd338..a610cdb56 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -2,7 +2,7 @@ @@ -47,7 +57,7 @@ class="flex items-center gap-3 rounded-lg border border-outline-gray-2 bg-surface-white px-4 py-2.5 shadow-lg">
- {{ progressMessage || "Generating page..." }} + {{ progressMessage || (mode === "modify" ? "Modifying section..." : "Generating page...") }} @@ -62,15 +72,25 @@ import useBuilderStore from "@/stores/builderStore"; import { createResource, Textarea } from "frappe-ui"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; -const props = defineProps<{ - modelValue: boolean; - pageId: string; -}>(); +const props = withDefaults( + defineProps<{ + modelValue: boolean; + pageId: string; + mode?: "generate" | "modify"; + blockContext?: Record | null; + }>(), + { + mode: "generate", + blockContext: null, + }, +); const emit = defineEmits<{ (e: "update:modelValue", value: boolean): void; (e: "generated", blocks: any[]): void; (e: "streaming", blocks: any[]): void; + (e: "modified", blocks: any[]): void; + (e: "modifyStreaming", blocks: any[]): void; (e: "openSettings"): void; }>(); @@ -143,6 +163,14 @@ const canGenerate = computed(() => { return prompt.value.trim() !== "" && hasAISettings.value && !generating.value; }); +const handleSubmit = async () => { + if (props.mode === "modify") { + await modifySection(); + } else { + await generatePage(); + } +}; + const generatePage = async () => { if (!canGenerate.value) return; @@ -170,6 +198,33 @@ const generatePage = async () => { } }; +const modifySection = async () => { + if (!canGenerate.value || !props.blockContext) return; + + generating.value = true; + errorMessage.value = ""; + progressMessage.value = "Initializing..."; + streamingContent.value = ""; + + showDialog.value = false; + + try { + await createResource({ + url: "builder.ai_page_generator.modify_section_from_prompt", + makeParams: () => ({ + prompt: prompt.value, + block_context: JSON.stringify(props.blockContext), + page_id: props.pageId, + }), + }).submit(); + } catch (error: any) { + generating.value = false; + progressMessage.value = ""; + showDialog.value = true; + errorMessage.value = error.message || "An error occurred while modifying the section"; + } +}; + watch(showDialog, (newValue) => { if (newValue) { errorMessage.value = ""; @@ -243,11 +298,61 @@ const onError = (data: any) => { errorMessage.value = data.message || "An error occurred while generating the page"; }; +// Modify mode realtime event handlers +const onModifyProgress = (data: any) => { + if (data.page_id && data.page_id !== props.pageId) return; + if (data.message) { + progressMessage.value = data.message; + } +}; + +const onModifyStream = (data: any) => { + if (data.page_id && data.page_id !== props.pageId) return; + if (data.chunk) { + streamingContent.value += data.chunk; + const repaired = repairPartialJSON(streamingContent.value); + if (repaired) { + try { + let parsed = JSON.parse(repaired); + let sections = Array.isArray(parsed) ? parsed : [parsed]; + sections = sections.filter((s: any) => s && typeof s === "object" && s.element); + if (sections.length > 0) { + emit("modifyStreaming", sections); + } + } catch { + // Still not valid enough, wait for more chunks + } + } + } +}; + +const onModifyComplete = (data: any) => { + if (data.page_id && data.page_id !== props.pageId) return; + generating.value = false; + progressMessage.value = ""; + if (data.blocks) { + emit("modified", data.blocks); + prompt.value = ""; + } +}; + +const onModifyError = (data: any) => { + if (data.page_id && data.page_id !== props.pageId) return; + generating.value = false; + progressMessage.value = ""; + showDialog.value = true; + errorMessage.value = data.message || "An error occurred while modifying the section"; +}; + onMounted(() => { builderStore.realtime.on("ai_generation_progress", onProgress); builderStore.realtime.on("ai_generation_stream", onStream); builderStore.realtime.on("ai_generation_complete", onComplete); builderStore.realtime.on("ai_generation_error", onError); + builderStore.realtime.on("ai_modify_progress", onModifyProgress); + builderStore.realtime.on("ai_modify_stream", onModifyStream); + builderStore.realtime.on("ai_modify_complete", onModifyComplete); + builderStore.realtime.on("ai_modify_error", onModifyError); }); onUnmounted(() => { @@ -255,6 +360,10 @@ onUnmounted(() => { builderStore.realtime.off("ai_generation_stream", onStream); builderStore.realtime.off("ai_generation_complete", onComplete); builderStore.realtime.off("ai_generation_error", onError); + builderStore.realtime.off("ai_modify_progress", onModifyProgress); + builderStore.realtime.off("ai_modify_stream", onModifyStream); + builderStore.realtime.off("ai_modify_complete", onModifyComplete); + builderStore.realtime.off("ai_modify_error", onModifyError); }); diff --git a/frontend/src/components/BlockContextMenu.vue b/frontend/src/components/BlockContextMenu.vue index 69a1f7a9d..c45556fcf 100644 --- a/frontend/src/components/BlockContextMenu.vue +++ b/frontend/src/components/BlockContextMenu.vue @@ -16,7 +16,7 @@ import useComponentStore from "@/stores/componentStore"; import getBlockTemplate from "@/utils/blockTemplate"; import { confirm, detachBlockFromComponent, getBlockCopy, triggerCopyEvent } from "@/utils/helpers"; import { useStorage } from "@vueuse/core"; -import { Ref, nextTick, ref } from "vue"; +import { Ref, inject, nextTick, ref } from "vue"; import { toast } from "vue-sonner"; const builderStore = useBuilderStore(); @@ -44,6 +44,8 @@ const showContextMenu = (event: MouseEvent, refBlock: Block) => { const copiedStyle = useStorage("copiedStyle", { blockId: "", style: {} }, sessionStorage) as Ref; +const editWithAIFn = inject<((block: Block) => void) | undefined>("editWithAI", undefined); + const copyStyle = () => { copiedStyle.value = { blockId: block.value.blockId, @@ -60,6 +62,16 @@ const duplicateBlock = () => { }; const contextMenuOptions: ContextMenuOption[] = [ + { + label: "Edit with AI", + action: () => { + if (editWithAIFn) { + editWithAIFn(block.value); + } + }, + condition: () => Boolean(editWithAIFn) && !block.value.isRoot(), + disabled: () => builderStore.readOnlyMode, + }, { label: "Edit HTML", action: () => { diff --git a/frontend/src/pages/PageBuilder.vue b/frontend/src/pages/PageBuilder.vue index 9cacc772d..822043f3d 100644 --- a/frontend/src/pages/PageBuilder.vue +++ b/frontend/src/pages/PageBuilder.vue @@ -102,9 +102,13 @@
+ @streaming="handleStreamingBlocks" + @modified="handleModifiedBlocks" + @modifyStreaming="handleModifyStreamingBlocks"> @@ -125,7 +129,7 @@ import usePageStore from "@/stores/pageStore"; import { BuilderPage } from "@/types/Builder/BuilderPage"; import { getUsersInfo } from "@/usersInfo"; import blockController from "@/utils/blockController"; -import { getBlockInstance, getRootBlockTemplate, isTargetEditable } from "@/utils/helpers"; +import { getBlockInstance, getBlockObject, getRootBlockTemplate, isTargetEditable } from "@/utils/helpers"; import { useBuilderEvents } from "@/utils/useBuilderEvents"; import { breakpointsTailwind, @@ -154,9 +158,24 @@ const componentUsedInPages = ref([]); const pageListDialog = ref(false); const blockContextMenu = ref | null>(null); const showAIGeneratorDialog = ref(false); +const aiMode = ref<"generate" | "modify">("generate"); +const modifyBlockContext = ref | null>(null); +const modifyBlockId = ref(null); // Expose AI generator dialog to toolbar provide("showAIGenerator", () => { + aiMode.value = "generate"; + modifyBlockContext.value = null; + modifyBlockId.value = null; + showAIGeneratorDialog.value = true; +}); + +// Expose edit-with-AI to context menu +provide("editWithAI", (block: any) => { + const blockObj = getBlockObject(block); + aiMode.value = "modify"; + modifyBlockContext.value = blockObj; + modifyBlockId.value = block.blockId; showAIGeneratorDialog.value = true; }); @@ -201,6 +220,71 @@ const handleStreamingBlocks = (blocks: any[]) => { } }; +// Find a block in the tree by blockId and replace its children/styles with new data +const replaceBlockInTree = (root: any, targetId: string, newBlocks: any[]): boolean => { + if (!root) return false; + if (root.blockId === targetId) { + // Replace this block's content with the first new block's content + const replacement = newBlocks[0]; + if (replacement) { + root.element = replacement.element || root.element; + root.baseStyles = replacement.baseStyles || root.baseStyles; + root.mobileStyles = replacement.mobileStyles || root.mobileStyles; + root.tabletStyles = replacement.tabletStyles || root.tabletStyles; + root.attributes = replacement.attributes || root.attributes; + root.classes = replacement.classes || root.classes; + + if (replacement.innerText !== undefined) root.innerText = replacement.innerText; + if (replacement.innerHTML !== undefined) root.innerHTML = replacement.innerHTML; + + // Replace children + if (replacement.children) { + root.children.splice(0, root.children.length); + for (const child of replacement.children) { + root.addChild(getBlockInstance(child)); + } + } + } + return true; + } + if (root.children) { + for (const child of root.children) { + if (replaceBlockInTree(child, targetId, newBlocks)) return true; + } + } + return false; +}; + +// Handle AI modified blocks (replace the specific block in the tree) +const handleModifiedBlocks = (blocks: any[]) => { + if (!blocks || blocks.length === 0 || !modifyBlockId.value) return; + + const rootBlock = pageStore.pageBlocks[0]; + if (rootBlock) { + replaceBlockInTree(rootBlock, modifyBlockId.value, blocks); + pageStore.savePage(); + } + + // Reset modify state + modifyBlockContext.value = null; + modifyBlockId.value = null; + aiMode.value = "generate"; +}; + +// Handle live streaming of modify (update block in-place) +const handleModifyStreamingBlocks = (blocks: any[]) => { + if (!blocks || blocks.length === 0 || !modifyBlockId.value) return; + + try { + const rootBlock = pageStore.pageBlocks[0]; + if (rootBlock) { + replaceBlockInTree(rootBlock, modifyBlockId.value, blocks); + } + } catch { + // Partial block may still be invalid, skip this frame + } +}; + watch([() => canvasStore.editableBlock, () => pageStore.activePage?.is_standard], () => { builderStore.toggleReadOnlyMode( canvasStore.editingMode === "page" && From 5817da205eb9fbb11c1c209bb4991c822bdf8ba4 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 11 Mar 2026 16:40:15 +0530 Subject: [PATCH 012/287] feat: Auto select relevant model based on task complexity --- builder/ai_page_generator.py | 863 +++++++++--------- .../src/components/AIPageGeneratorModal.vue | 43 +- 2 files changed, 486 insertions(+), 420 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index 78d4c83ba..2b8499824 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -1,13 +1,295 @@ -""" -AI Page Generator -Generates Builder pages from natural language prompts using various LLM providers -""" - import json +import re import frappe from frappe import _ +# ─── Task Classification ─────────────────────────────────────────── + +TRIVIAL_PATTERNS = [ + r"\b(change|update|rename|rephrase|reword|fix|correct|replace)\b.*\b(text|title|heading|label|typo|word|name|copy|content|string)\b", + r"\b(change|update|set|make)\b.*\b(colou?r|font|size|bold|italic|underline|weight)\b", + r"\b(change|update|set)\b.*\b(background|bg)\s*(colou?r)?\b", + r"\bmake\s+(it\s+)?(bigger|smaller|larger|bolder|lighter|darker|brighter)\b", +] + +SIMPLE_PATTERNS = [ + r"\b(add|insert|include|put)\b.*\b(button|link|icon|image|img|divider|spacer|badge|tag)\b", + r"\b(change|update|set|adjust|modify|tweak)\b.*\b(padding|margin|spacing|gap|border|shadow|radius|opacity|alignment|align|width|height)\b", + r"\b(center|left.?align|right.?align|justify)\b", + r"\b(hide|show|remove|delete|toggle)\b.*\b(block|element|section|button|text|image|div)\b", + r"\b(move|swap|reorder|rearrange)\b", + r"\b(round|rounded|square|circle|pill)\b.*\b(corner|border|shape)\b", +] + +COMPLEX_PATTERNS = [ + r"\b(create|build|generate|make|design)\b.*\b(page|landing|website|full|complete)\b", + r"\b(from\s+scratch|entire|whole|complete|multi.?section)\b", +] + + +def classify_task(prompt: str, is_modify: bool = False) -> str: + """Classify prompt complexity → trivial | simple | moderate | complex.""" + lower = prompt.lower().strip() + + if not is_modify: + for p in COMPLEX_PATTERNS: + if re.search(p, lower): + return "complex" + return "moderate" + + for p in TRIVIAL_PATTERNS: + if re.search(p, lower): + return "trivial" + for p in SIMPLE_PATTERNS: + if re.search(p, lower): + return "simple" + for p in COMPLEX_PATTERNS: + if re.search(p, lower): + return "complex" + return "moderate" + + +# ─── Model Selection & Cost Intelligence ────────────────────────── + +MODEL_TIERS = { + "openai": {"trivial": "gpt-5-nano", "simple": "gpt-4.1-mini", "moderate": "gpt-4.1", "complex": "gpt-5"}, + "anthropic": { + "trivial": "claude-haiku-4-5", + "simple": "claude-haiku-4-5", + "moderate": "claude-sonnet-4-6", + "complex": "claude-sonnet-4-6", + }, + "google": { + "trivial": "gemini-1.5-flash", + "simple": "gemini-1.5-flash", + "moderate": "gemini-2.0-flash-exp", + "complex": "gemini-1.5-pro", + }, + "x-ai": { + "trivial": "grok-beta", + "simple": "grok-beta", + "moderate": "grok-2-1212", + "complex": "grok-2-1212", + }, +} + +MODEL_COST_INDEX = { + "gpt-5-nano": 1, + "gpt-5-mini": 2, + "gpt-4.1-mini": 2, + "o4-mini": 3, + "gpt-4.1": 4, + "gpt-5": 5, + "o3": 6, + "gpt-5.4": 7, + "claude-haiku-4-5": 1, + "claude-sonnet-4-6": 4, + "claude-opus-4-6": 7, + "gemini-1.5-flash": 1, + "gemini-2.0-flash-exp": 2, + "gemini-1.5-pro": 5, + "grok-beta": 2, + "grok-2-1212": 4, + "grok-2-vision-1212": 4, +} + +TASK_PARAMS = { + "trivial": {"max_tokens": 2000, "temperature": 0.3}, + "simple": {"max_tokens": 4000, "temperature": 0.5}, + "moderate": {"max_tokens": 8000, "temperature": 0.7}, + "complex": {"max_tokens": 12000, "temperature": 0.7}, +} + +TIER_ORDER = ["trivial", "simple", "moderate", "complex"] + + +def get_optimal_model(configured_model: str, task_tier: str) -> str: + """Pick the cheapest adequate model, capped at the user's configured model.""" + provider = get_provider_from_model(configured_model) + tier_map = MODEL_TIERS.get(provider) + if not tier_map: + return configured_model + optimal = tier_map.get(task_tier, configured_model) + configured_cost = MODEL_COST_INDEX.get(configured_model, 5) + optimal_cost = MODEL_COST_INDEX.get(optimal, 5) + if optimal_cost > configured_cost: + return configured_model + return optimal + + +def get_escalated_model(current_model: str, configured_model: str) -> str | None: + """Next-tier fallback model, capped at configured model's cost.""" + provider = get_provider_from_model(current_model) + tier_map = MODEL_TIERS.get(provider) + if not tier_map: + return None + reverse_map = {v: k for k, v in tier_map.items()} + current_tier = reverse_map.get(current_model) + if not current_tier or current_tier not in TIER_ORDER: + return None + idx = TIER_ORDER.index(current_tier) + if idx >= len(TIER_ORDER) - 1: + return None + next_model = tier_map.get(TIER_ORDER[idx + 1]) + if not next_model or next_model == current_model: + return None + configured_cost = MODEL_COST_INDEX.get(configured_model, 5) + if MODEL_COST_INDEX.get(next_model, 5) > configured_cost: + return configured_model if configured_model != current_model else None + return next_model + + +# ─── Tiered System Prompts ──────────────────────────────────────── + +MINIMAL_MODIFY_PROMPT = ( + "You modify web sections in Frappe Builder's block system.\n" + "Return ONLY valid JSON array. No markdown, no text. Start with [ end with ].\n\n" + 'Block: {"element":"tag","blockName":"n","blockId":"id","baseStyles":{...},' + '"children":[...],"attributes":{...},"innerText":"t","mobileStyles":{...},"tabletStyles":{...}}\n\n' + "Preserve all blockId values. Only change what was asked. Use camelCase CSS.\n" + "Wrap text in semantic elements (p, h1-h3, span, a) — never place text directly in div/section." +) + +FOCUSED_MODIFY_PROMPT = ( + "You modify web sections in Frappe Builder's block system.\n" + "Return ONLY valid JSON array. No markdown, no explanations. Start with [ end with ].\n\n" + "Block props: element, blockName, blockId, baseStyles (CSS-in-JS camelCase), " + "mobileStyles, tabletStyles, attributes, innerText/innerHTML, children, classes.\n" + 'Style example: {"display":"flex","flexDirection":"column","padding":"2rem","backgroundColor":"#fff"}\n\n' + "Rules: Preserve blockId values. Only change what requested. Return COMPLETE block structure. " + "Use %, rem for responsive widths.\n" + "Wrap text in semantic elements (p, h1-h3, span, a) — never place text directly in div/section." +) + +COMPACT_GENERATION_PROMPT = ( + "You generate web sections using Frappe Builder's block system.\n" + "Return ONLY valid JSON array. No markdown, no text. Start with [ end with ].\n\n" + 'Block: {"element":"section","blockName":"hero","baseStyles":{"display":"flex",' + '"flexDirection":"column","padding":"2rem"},"children":[...],"attributes":{},' + '"mobileStyles":{},"tabletStyles":{}}\n\n' + "Elements: section, div, nav, header, footer, h1-h3, p, span, button, a, img\n" + "Styles: CSS-in-JS camelCase \u2014 display, flexDirection, padding, margin, gap, " + "backgroundColor, color, fontSize, fontWeight, width, minHeight, background, " + "boxShadow, borderRadius, gridTemplateColumns\n" + "Attributes: src, alt, href, target | Text: innerText or innerHTML\n\n" + "Design: flex/grid layouts, 100% widths, modern colors, Google Fonts via fontFamily " + '(use ONLY the font name e.g. "Bebas Neue" — never add fallback families like cursive or sans-serif), ' + "responsive mobileStyles overrides.\n" + "Wrap text in semantic elements (p, h1-h3, span, a) — never place text directly in div/section." +) + + +def get_system_prompt_for_tier(task_tier: str, is_modify: bool) -> str: + """Select the most token-efficient prompt for the task tier.""" + if is_modify: + if task_tier == "trivial": + return MINIMAL_MODIFY_PROMPT + if task_tier == "simple": + return FOCUSED_MODIFY_PROMPT + return get_modify_system_prompt() + if task_tier in ("trivial", "simple"): + return COMPACT_GENERATION_PROMPT + return get_system_prompt() + + +# ─── Context Optimization ───────────────────────────────────────── + + +def strip_block_context(block_context: str, task_tier: str) -> str: + """Remove empty/default properties from block JSON to reduce input tokens.""" + try: + data = json.loads(block_context) + except (json.JSONDecodeError, TypeError): + return block_context + + if isinstance(data, dict): + data = [data] + + def _strip(block, depth=0): + if not isinstance(block, dict): + return block + out = {} + for k, v in block.items(): + if v is None: + continue + if isinstance(v, dict) and not v: + continue + if isinstance(v, list) and not v and k != "children": + continue + if isinstance(v, str) and not v and k not in ("innerText", "innerHTML"): + continue + if k == "children" and isinstance(v, list): + children = [_strip(c, depth + 1) for c in v if isinstance(c, dict)] + if children: + out[k] = children + else: + out[k] = v + if task_tier == "trivial" and depth > 1: + out.pop("mobileStyles", None) + out.pop("tabletStyles", None) + out.pop("classes", None) + return out + + stripped = [_strip(b) for b in data if isinstance(b, dict)] + return json.dumps(stripped, separators=(",", ":")) + + +def build_user_message(prompt: str, task_tier: str, is_modify: bool, block_context: str | None = None) -> str: + """Build a token-efficient user message.""" + if is_modify and block_context: + if task_tier in ("trivial", "simple"): + return f"Block:\n{block_context}\n\nChange: {prompt}" + return f"Current section:\n{block_context}\n\nModify: {prompt}" + if task_tier in ("trivial", "simple"): + return f"Create: {prompt}" + return f"Create a section for: {prompt}" + + +# ─── LLM Helpers ────────────────────────────────────────────────── + + +def _call_llm(model: str, messages: list, params: dict, *, stream: bool = True): + """Unified litellm call. Returns iterator (stream) or string (non-stream).""" + import litellm + + litellm.drop_params = True + if stream: + return litellm.completion( + model=model, + messages=messages, + temperature=params["temperature"], + max_tokens=params["max_tokens"], + stream=True, + ) + resp = litellm.completion( + model=model, + messages=messages, + temperature=params["temperature"], + max_tokens=params["max_tokens"], + stream=False, + ) + return resp.choices[0].message.content or "" + + +def _parse_blocks(content: str) -> list[dict]: + """Parse LLM output into block list. Raises on invalid output.""" + content = content.strip() + if content.startswith("```"): + content = content.split("\n", 1)[1] if "\n" in content else content[3:] + if content.endswith("```"): + content = content[:-3].strip() + parsed = json.loads(content) + if isinstance(parsed, dict): + blocks = [parsed] + elif isinstance(parsed, list): + blocks = [b for b in parsed if isinstance(b, dict)] + else: + raise ValueError("Not a valid block object") + if not blocks: + raise ValueError("No valid blocks in response") + return blocks + def get_available_models(): """Return list of available AI models""" @@ -57,154 +339,35 @@ def get_available_models(): def get_system_prompt(): - """Return the system prompt for page generation""" - return """You are an expert web designer and developer specializing in creating beautiful, modern, and responsive web pages using the Frappe Builder block system. - -Your task is to generate a complete page structure based on user prompts. You must return ONLY a valid JSON array of blocks, with no additional text, markdown formatting, or explanations. - -# Block Structure Rules: - -2. **Common Elements**: Use semantic HTML elements: - - Container: "div" with display: flex/grid - - Text: "h1", "h2", "h3", "p", "span" - - Buttons: "button" or "a" - - Images: "img" with src attribute - - Sections: "section", "header", "footer", "nav" - - Font: Choose creative/relevant fonts from Google Fonts and specify in baseStyles (e.g., "fontFamily": "Roboto") - -3. **Styling (baseStyles)**: Use CSS-in-JS object format: -```json -{ - "baseStyles": { - "display": "flex", - "flexDirection": "column", - "padding": "2rem", - "backgroundColor": "#ffffff", - "fontSize": "16px", - "fontWeight": "600" - } -} -``` - -4. **Responsive Styles**: Add mobile/tablet overrides: -```json -{ - "mobileStyles": { - "fontSize": "14px", - "padding": "1rem" - }, - "tabletStyles": { - "fontSize": "15px" - } -} -``` - -5. **Attributes**: Add HTML attributes: -```json -{ - "attributes": { - "src": "/path/to/image.jpg", - "alt": "Image description", - "href": "https://example.com", - "target": "_blank" - } -} -``` - -6. **Text Content**: Use innerText or innerHTML: -```json -{ - "element": "h1", - "blockName": "hero-title", - "innerText": "Welcome to Our Site" -} -``` + """Full system prompt for complex page generation (moderate/complex tiers).""" + return """You are an expert web designer specializing in creating modern, responsive web pages using the Frappe Builder block system. -7. **Classes**: Add CSS classes if needed: -```json -{ - "classes": ["hero-section", "text-center"] -} -``` - -# Design Best Practices: - -1. **Modern Design**: Use contemporary design patterns (flexbox, grid layouts) -2. **Color Schemes**: Use harmonious color palettes -3. **Typography**: Use appropriate font sizes and weights -4. **Spacing**: Apply consistent padding and margins -5. **Responsive**: Ensure mobile-first responsive design, set relative units (%, rem) for widths. -6. **Accessibility**: Include proper semantic HTML and alt texts - -# Common Layouts: - -**Hero Section**: -```json -{ - "element": "section", - "blockName": "hero-section", - "baseStyles": { - "display": "flex", - "flexDirection": "column", - "alignItems": "center", - "justifyContent": "center", - "minHeight": "80vh", - "padding": "4rem 2rem", - "width": "100%", - "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - "color": "#ffffff" - }, - "children": [...] -} -``` - -**Card Grid**: -```json -{ - "element": "div", - "blockName": "product-grid", - "baseStyles": { - "display": "grid", - "gridTemplateColumns": "repeat(auto-fit, minmax(300px, 1fr))", - "gap": "2rem", - "padding": "2rem" - }, - "children": [...] -} -``` - -**Navigation Bar**: -```json -{ - "element": "nav", - "blockName": "main-nav", - "baseStyles": { - "display": "flex", - "justifyContent": "space-between", - "alignItems": "center", - "padding": "1rem 2rem", - "width": "100%", - "backgroundColor": "#ffffff", - "boxShadow": "0 2px 4px rgba(0,0,0,0.1)" - }, - "children": [...] -} -``` +Return ONLY a valid JSON array of blocks. No markdown, no explanations. Start with [ end with ]. -# CRITICAL: Output Format +# Block Structure: +- element: semantic HTML tag (section, div, nav, header, footer, h1-h3, p, span, button, a, img) +- blockName: descriptive name +- baseStyles: CSS-in-JS camelCase object (display, flexDirection, padding, margin, gap, backgroundColor, color, fontSize, fontWeight, width, minHeight, background, boxShadow, borderRadius, gridTemplateColumns, fontFamily) +- mobileStyles: mobile overrides (fontSize, padding, flexDirection, etc.) +- tabletStyles: tablet overrides +- attributes: HTML attrs (src, alt, href, target) +- innerText or innerHTML: text content +- children: nested blocks array +- classes: CSS class names -Return ONLY a JSON array with no markdown code blocks, no explanations, no additional text. Start directly with `[` and end with `]`. +# Style Format: +{"display":"flex","flexDirection":"column","padding":"2rem","backgroundColor":"#ffffff"} -Example output: -[{"element":"section","baseStyles":{"display":"flex"},"children":[...],attributes:{},blockName:"hero-section"}, {"element":"div","baseStyles":{"display":"grid"},"children":[...]}, ...] +# Rules: +- Use flex/grid layouts with 100% widths +- Modern harmonious color palettes +- Google Fonts via fontFamily in baseStyles (use ONLY the font name, e.g. "Bebas Neue" — do NOT add CSS fallback families like cursive, sans-serif, etc.) +- Responsive: %, rem, auto-fit units; add mobileStyles overrides +- Semantic HTML with alt texts for images +- Consistent padding/margins +- Wrap text (innerText/innerHTML) in semantic text elements (p, h1-h3, span, a) — never place text directly in div/section. -Remember: -- Generate complete, production-ready pages -- Use modern design principles -- Ensure responsive design -- Return ONLY valid JSON -- No markdown, no explanations, just JSON -- Make sure widths are set to 100% for responsiveness""" +# CRITICAL: Return ONLY valid JSON array. No markdown code blocks, no text outside the array.""" def generate_page_blocks( @@ -214,30 +377,16 @@ def generate_page_blocks( user: str | None = None, page_id: str | None = None, ): - """ - Generate page blocks from a prompt using the specified AI model with streaming. - - Runs as a background job. Streams chunks and publishes final blocks via realtime. - - Args: - prompt: User's description of the page they want - model: Model identifier (e.g., 'gpt-5.4', 'claude-sonnet-4-6') - api_key: Optional API key (if not configured in site config) - user: The user to publish realtime events to - page_id: Builder Page ID to scope realtime events to - """ + """Smart page generation with auto model selection and fallback escalation.""" user = user or frappe.session.user content = "" try: try: - import litellm + import litellm # noqa: F401 except ImportError: frappe.publish_realtime( "ai_generation_error", - { - "page_id": page_id, - "message": "litellm library is not installed. Please install it using: pip install litellm", - }, + {"page_id": page_id, "message": "litellm is not installed. Run: pip install litellm"}, user=user, ) return @@ -250,110 +399,93 @@ def generate_page_blocks( "ai_generation_error", { "page_id": page_id, - "message": f"API key not configured for model: {model}. Please configure it in Settings \u2192 AI.", + "message": f"API key not configured for {model}. Configure in Settings \u2192 AI.", }, user=user, ) return - set_api_key_for_provider(model, api_key) + # ── Smart routing ── + task_tier = classify_task(prompt, is_modify=False) + optimal_model = get_optimal_model(model, task_tier) + params = TASK_PARAMS[task_tier] + should_stream = task_tier in ("moderate", "complex") + + set_api_key_for_provider(optimal_model, api_key) + tier_label = {"trivial": "quick", "simple": "fast", "moderate": "standard", "complex": "full"}[ + task_tier + ] frappe.publish_realtime( "ai_generation_progress", - {"page_id": page_id, "status": "preparing", "message": "Preparing AI request..."}, + { + "page_id": page_id, + "status": "generating", + "message": f"Generating ({tier_label}) with {optimal_model}...", + "task_tier": task_tier, + "model_used": optimal_model, + }, user=user, ) + system_prompt = get_system_prompt_for_tier(task_tier, is_modify=False) + user_message = build_user_message(prompt, task_tier, is_modify=False) messages = [ - {"role": "system", "content": get_system_prompt()}, - {"role": "user", "content": f"Create a section for: {prompt}"}, + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, ] - frappe.publish_realtime( - "ai_generation_progress", - {"page_id": page_id, "status": "generating", "message": f"Generating page with {model}..."}, - user=user, - ) - - # Stream the response so the frontend can show progressive output - # GPT-5+ models only support temperature=1; use drop_params to - # let litellm silently strip unsupported kwargs for any model. - litellm.drop_params = True - response = litellm.completion( - model=model, - messages=messages, - temperature=0.7, - max_tokens=12000, - stream=True, - ) + # ── LLM call (stream for complex, single-shot for simple) ── + if should_stream: + response = _call_llm(optimal_model, messages, params, stream=True) + for chunk in response: + delta = chunk.choices[0].delta.content + if delta: + content += delta + frappe.publish_realtime( + "ai_generation_stream", + {"page_id": page_id, "chunk": delta}, + user=user, + ) + else: + content = _call_llm(optimal_model, messages, params, stream=False) - content = "" - for chunk in response: - delta = chunk.choices[0].delta.content - if delta: - content += delta + # ── Parse with automatic escalation on failure ── + try: + blocks = _parse_blocks(content) + except (json.JSONDecodeError, ValueError): + escalated = get_escalated_model(optimal_model, model) + if escalated and escalated != optimal_model: frappe.publish_realtime( - "ai_generation_stream", - {"page_id": page_id, "chunk": delta, "accumulated": content}, + "ai_generation_progress", + {"page_id": page_id, "message": f"Refining with {escalated}..."}, user=user, ) - - content = content.strip() - - frappe.publish_realtime( - "ai_generation_progress", - {"page_id": page_id, "status": "parsing", "message": "Parsing generated content..."}, - user=user, - ) - - # Remove markdown code blocks if present - if content.startswith("```"): - content = content.split("\n", 1)[1] if "\n" in content else content[3:] - if content.endswith("```"): - content = content[:-3].strip() - - parsed = json.loads(content) - - # Normalize: ensure we have a list of section blocks - if isinstance(parsed, dict): - blocks = [parsed] - elif isinstance(parsed, list): - blocks = [b for b in parsed if isinstance(b, dict)] - else: - frappe.publish_realtime( - "ai_generation_error", - {"page_id": page_id, "message": "AI response is not a valid block object"}, - user=user, - ) - return - - if not blocks: - frappe.publish_realtime( - "ai_generation_error", - {"page_id": page_id, "message": "AI response contained no valid blocks"}, - user=user, - ) - return + set_api_key_for_provider(escalated, api_key) + next_idx = min(TIER_ORDER.index(task_tier) + 1, len(TIER_ORDER) - 1) + next_tier = TIER_ORDER[next_idx] + messages[0]["content"] = get_system_prompt_for_tier(next_tier, is_modify=False) + content = _call_llm(escalated, messages, TASK_PARAMS[next_tier], stream=False) + blocks = _parse_blocks(content) + else: + raise frappe.publish_realtime( "ai_generation_complete", - {"page_id": page_id, "blocks": blocks}, + {"page_id": page_id, "blocks": blocks, "model_used": optimal_model, "task_tier": task_tier}, user=user, ) except json.JSONDecodeError as e: - frappe.log_error(f"JSON parsing error: {e!s}\nContent: {content}", "AI Page Generation Error") + frappe.log_error(f"JSON parse error: {e!s}\nContent: {content}", "AI Page Generation") frappe.publish_realtime( "ai_generation_error", - { - "page_id": page_id, - "message": "Failed to parse AI response as JSON. The model may have returned invalid output.", - }, + {"page_id": page_id, "message": "Failed to parse AI response. The model returned invalid JSON."}, user=user, ) except Exception as e: - frappe.log_error(f"AI generation error: {e!s}", "AI Page Generation Error") + frappe.log_error(f"AI generation error: {e!s}", "AI Page Generation") frappe.publish_realtime( "ai_generation_error", {"page_id": page_id, "message": f"Failed to generate page: {e!s}"}, @@ -380,8 +512,8 @@ def get_api_key_for_model(model: str) -> str | None: def get_provider_from_model(model: str) -> str: - """Determine provider from model name""" - if model.startswith(("gpt-", "chatgpt-", "o1", "o3")): + """Determine provider from model name.""" + if model.startswith(("gpt-", "chatgpt-", "o1", "o3", "o4")): return "openai" elif model.startswith("claude-"): return "anthropic" @@ -412,96 +544,25 @@ def set_api_key_for_provider(model: str, api_key: str): def get_modify_system_prompt(): - """Return the system prompt for modifying an existing section""" - return """You are an expert web designer and developer specializing in modifying web page sections using the Frappe Builder block system. - -Your task is to modify an existing block structure based on user instructions. You will receive the current JSON block structure and an instruction describing the desired changes. - -You must return ONLY a valid JSON array of blocks (the modified version), with no additional text, markdown formatting, or explanations. - -# Block Structure Rules: - -1. **Common Elements**: Use semantic HTML elements: - - Container: "div" with display: flex/grid - - Text: "h1", "h2", "h3", "p", "span" - - Buttons: "button" or "a" - - Images: "img" with src attribute - - Sections: "section", "header", "footer", "nav" - - Font: Choose creative/relevant fonts from Google Fonts and specify in baseStyles (e.g., "fontFamily": "Roboto") - -2. **Styling (baseStyles)**: Use CSS-in-JS object format: -```json -{ - "baseStyles": { - "display": "flex", - "flexDirection": "column", - "padding": "2rem", - "backgroundColor": "#ffffff", - "fontSize": "16px", - "fontWeight": "600" - } -} -``` - -3. **Responsive Styles**: Add mobile/tablet overrides: -```json -{ - "mobileStyles": { - "fontSize": "14px", - "padding": "1rem" - }, - "tabletStyles": { - "fontSize": "15px" - } -} -``` - -4. **Attributes**: Add HTML attributes: -```json -{ - "attributes": { - "src": "/path/to/image.jpg", - "alt": "Image description", - "href": "https://example.com", - "target": "_blank" - } -} -``` - -5. **Text Content**: Use innerText or innerHTML: -```json -{ - "element": "h1", - "blockName": "hero-title", - "innerText": "Welcome to Our Site" -} -``` - -6. **Classes**: Add CSS classes if needed: -```json -{ - "classes": ["hero-section", "text-center"] -} -``` + """Full system prompt for complex section modification (moderate/complex tiers).""" + return """You are an expert web designer specializing in modifying web page sections using the Frappe Builder block system. -# Important Modification Guidelines: +Modify the existing block structure based on user instructions. Return ONLY a valid JSON array of the modified blocks. No markdown, no explanations. -1. **Preserve Structure**: Keep existing block structure where the user hasn't asked for changes. -2. **Maintain blockId**: Preserve existing blockId values to minimize DOM churn. -3. **Surgical Changes**: Only modify what the user asks for — don't redesign the entire section unless requested. -4. **Responsive**: Ensure mobile-first responsive design, set relative units (%, rem) for widths. +# Block Structure: +- element, blockName, blockId, baseStyles (CSS-in-JS camelCase), mobileStyles, tabletStyles +- attributes (src, alt, href, target), innerText/innerHTML, children, classes -# CRITICAL: Output Format +# Modification Rules: +1. Preserve ALL existing blockId values to prevent DOM churn +2. Only modify what the user explicitly asks for +3. Keep existing structure intact where not asked to change +4. Return the COMPLETE modified block structure (not a diff) +5. Use responsive units (%, rem, auto-fit) for widths +6. Style format: {"display":"flex","flexDirection":"column","padding":"2rem"} +7. Wrap text (innerText/innerHTML) in semantic text elements (p, h1-h3, span, a) — never place text directly in div/section -Return ONLY a JSON array with no markdown code blocks, no explanations, no additional text. Start directly with `[` and end with `]`. - -The output must be the complete modified block structure (not a diff or patch). - -Remember: -- Return the full modified block structure -- Preserve existing structure where not explicitly asked to change -- Return ONLY valid JSON -- No markdown, no explanations, just JSON""" +# CRITICAL: Return ONLY valid JSON array. Start with [ end with ].""" def modify_section_blocks( @@ -512,31 +573,16 @@ def modify_section_blocks( user: str | None = None, page_id: str | None = None, ): - """ - Modify existing section blocks based on a prompt using the specified AI model with streaming. - - Runs as a background job. Streams chunks and publishes final blocks via realtime. - - Args: - prompt: User's description of the modification they want - block_context: JSON string of the existing block structure to modify - model: Model identifier (e.g., 'gpt-5.4', 'claude-sonnet-4-6') - api_key: Optional API key (if not configured in site config) - user: The user to publish realtime events to - page_id: Builder Page ID to scope realtime events to - """ + """Smart section modification with context stripping and auto model selection.""" user = user or frappe.session.user content = "" try: try: - import litellm + import litellm # noqa: F401 except ImportError: frappe.publish_realtime( "ai_modify_error", - { - "page_id": page_id, - "message": "litellm library is not installed. Please install it using: pip install litellm", - }, + {"page_id": page_id, "message": "litellm is not installed. Run: pip install litellm"}, user=user, ) return @@ -549,109 +595,96 @@ def modify_section_blocks( "ai_modify_error", { "page_id": page_id, - "message": f"API key not configured for model: {model}. Please configure it in Settings → AI.", + "message": f"API key not configured for {model}. Configure in Settings \u2192 AI.", }, user=user, ) return - set_api_key_for_provider(model, api_key) + # ── Smart routing ── + task_tier = classify_task(prompt, is_modify=True) + optimal_model = get_optimal_model(model, task_tier) + params = TASK_PARAMS[task_tier] + should_stream = task_tier in ("moderate", "complex") + + # Strip context to save tokens + stripped_context = strip_block_context(block_context, task_tier) + + set_api_key_for_provider(optimal_model, api_key) + tier_label = {"trivial": "quick", "simple": "fast", "moderate": "standard", "complex": "full"}[ + task_tier + ] frappe.publish_realtime( "ai_modify_progress", - {"page_id": page_id, "status": "preparing", "message": "Preparing AI request..."}, + { + "page_id": page_id, + "status": "generating", + "message": f"Modifying ({tier_label}) with {optimal_model}...", + "task_tier": task_tier, + "model_used": optimal_model, + }, user=user, ) - user_message = f"Here is the current section structure:\n```json\n{block_context}\n```\n\nModify it according to: {prompt}" - + system_prompt = get_system_prompt_for_tier(task_tier, is_modify=True) + user_message = build_user_message(prompt, task_tier, is_modify=True, block_context=stripped_context) messages = [ - {"role": "system", "content": get_modify_system_prompt()}, + {"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}, ] - frappe.publish_realtime( - "ai_modify_progress", - {"page_id": page_id, "status": "generating", "message": f"Modifying section with {model}..."}, - user=user, - ) - - litellm.drop_params = True - response = litellm.completion( - model=model, - messages=messages, - temperature=0.7, - max_tokens=12000, - stream=True, - ) + # ── LLM call (stream for complex, single-shot for simple) ── + if should_stream: + response = _call_llm(optimal_model, messages, params, stream=True) + for chunk in response: + delta = chunk.choices[0].delta.content + if delta: + content += delta + frappe.publish_realtime( + "ai_modify_stream", + {"page_id": page_id, "chunk": delta}, + user=user, + ) + else: + content = _call_llm(optimal_model, messages, params, stream=False) - content = "" - for chunk in response: - delta = chunk.choices[0].delta.content - if delta: - content += delta + # ── Parse with automatic escalation on failure ── + try: + blocks = _parse_blocks(content) + except (json.JSONDecodeError, ValueError): + escalated = get_escalated_model(optimal_model, model) + if escalated and escalated != optimal_model: frappe.publish_realtime( - "ai_modify_stream", - {"page_id": page_id, "chunk": delta, "accumulated": content}, + "ai_modify_progress", + {"page_id": page_id, "message": f"Refining with {escalated}..."}, user=user, ) - - content = content.strip() - - frappe.publish_realtime( - "ai_modify_progress", - {"page_id": page_id, "status": "parsing", "message": "Parsing modified content..."}, - user=user, - ) - - # Remove markdown code blocks if present - if content.startswith("```"): - content = content.split("\n", 1)[1] if "\n" in content else content[3:] - if content.endswith("```"): - content = content[:-3].strip() - - parsed = json.loads(content) - - # Normalize: ensure we have a list of section blocks - if isinstance(parsed, dict): - blocks = [parsed] - elif isinstance(parsed, list): - blocks = [b for b in parsed if isinstance(b, dict)] - else: - frappe.publish_realtime( - "ai_modify_error", - {"page_id": page_id, "message": "AI response is not a valid block object"}, - user=user, - ) - return - - if not blocks: - frappe.publish_realtime( - "ai_modify_error", - {"page_id": page_id, "message": "AI response contained no valid blocks"}, - user=user, - ) - return + set_api_key_for_provider(escalated, api_key) + next_idx = min(TIER_ORDER.index(task_tier) + 1, len(TIER_ORDER) - 1) + next_tier = TIER_ORDER[next_idx] + messages[0]["content"] = get_system_prompt_for_tier(next_tier, is_modify=True) + content = _call_llm(escalated, messages, TASK_PARAMS[next_tier], stream=False) + blocks = _parse_blocks(content) + else: + raise frappe.publish_realtime( "ai_modify_complete", - {"page_id": page_id, "blocks": blocks}, + {"page_id": page_id, "blocks": blocks, "model_used": optimal_model, "task_tier": task_tier}, user=user, ) except json.JSONDecodeError as e: - frappe.log_error(f"JSON parsing error: {e!s}\nContent: {content}", "AI Section Modify Error") + frappe.log_error(f"JSON parse error: {e!s}\nContent: {content}", "AI Section Modify") frappe.publish_realtime( "ai_modify_error", - { - "page_id": page_id, - "message": "Failed to parse AI response as JSON. The model may have returned invalid output.", - }, + {"page_id": page_id, "message": "Failed to parse AI response. The model returned invalid JSON."}, user=user, ) except Exception as e: - frappe.log_error(f"AI modify error: {e!s}", "AI Section Modify Error") + frappe.log_error(f"AI modify error: {e!s}", "AI Section Modify") frappe.publish_realtime( "ai_modify_error", {"page_id": page_id, "message": f"Failed to modify section: {e!s}"}, diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index a610cdb56..68eb6b904 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -52,10 +52,15 @@ -
+
-
+
+ {{ progressMessage || (mode === "modify" ? "Modifying section..." : "Generating page...") }} @@ -69,7 +74,7 @@ import Dialog from "@/components/Controls/Dialog.vue"; import { builderSettings } from "@/data/builderSettings"; import useBuilderStore from "@/stores/builderStore"; -import { createResource, Textarea } from "frappe-ui"; +import { createResource, FeatherIcon, Textarea } from "frappe-ui"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; const props = withDefaults( @@ -283,7 +288,21 @@ const onStream = (data: any) => { const onComplete = (data: any) => { if (data.page_id && data.page_id !== props.pageId) return; generating.value = false; - progressMessage.value = ""; + if (data.model_used) { + const tierLabels: Record = { + trivial: "quick", + simple: "fast", + moderate: "standard", + complex: "full", + }; + const tierLabel = tierLabels[data.task_tier as string] || ""; + progressMessage.value = `Done — ${tierLabel} mode with ${data.model_used}`; + setTimeout(() => { + progressMessage.value = ""; + }, 2000); + } else { + progressMessage.value = ""; + } if (data.blocks) { emit("generated", data.blocks); prompt.value = ""; @@ -329,7 +348,21 @@ const onModifyStream = (data: any) => { const onModifyComplete = (data: any) => { if (data.page_id && data.page_id !== props.pageId) return; generating.value = false; - progressMessage.value = ""; + if (data.model_used) { + const tierLabels: Record = { + trivial: "quick", + simple: "fast", + moderate: "standard", + complex: "full", + }; + const tierLabel = tierLabels[data.task_tier as string] || ""; + progressMessage.value = `Done — ${tierLabel} mode with ${data.model_used}`; + setTimeout(() => { + progressMessage.value = ""; + }, 2000); + } else { + progressMessage.value = ""; + } if (data.blocks) { emit("modified", data.blocks); prompt.value = ""; From 6a3dc7fe8e3682a7bd0e5eeef82fce8d024528df Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sat, 14 Mar 2026 16:07:34 +0530 Subject: [PATCH 013/287] refactor: Remove unnecessary code, upgrade gemini models --- builder/ai_page_generator.py | 134 ++++++++++++----------------------- 1 file changed, 47 insertions(+), 87 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index 2b8499824..11943c2bb 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -61,10 +61,10 @@ def classify_task(prompt: str, is_modify: bool = False) -> str: "complex": "claude-sonnet-4-6", }, "google": { - "trivial": "gemini-1.5-flash", - "simple": "gemini-1.5-flash", - "moderate": "gemini-2.0-flash-exp", - "complex": "gemini-1.5-pro", + "trivial": "gemini-2.5-flash-lite", + "simple": "gemini-2.5-flash", + "moderate": "gemini-2.5-flash", + "complex": "gemini-2.5-pro", }, "x-ai": { "trivial": "grok-beta", @@ -86,9 +86,9 @@ def classify_task(prompt: str, is_modify: bool = False) -> str: "claude-haiku-4-5": 1, "claude-sonnet-4-6": 4, "claude-opus-4-6": 7, - "gemini-1.5-flash": 1, - "gemini-2.0-flash-exp": 2, - "gemini-1.5-pro": 5, + "gemini-2.5-flash-lite": 1, + "gemini-2.5-flash": 2, + "gemini-2.5-pro": 5, "grok-beta": 2, "grok-2-1212": 4, "grok-2-vision-1212": 4, @@ -97,8 +97,8 @@ def classify_task(prompt: str, is_modify: bool = False) -> str: TASK_PARAMS = { "trivial": {"max_tokens": 2000, "temperature": 0.3}, "simple": {"max_tokens": 4000, "temperature": 0.5}, - "moderate": {"max_tokens": 8000, "temperature": 0.7}, - "complex": {"max_tokens": 12000, "temperature": 0.7}, + "moderate": {"max_tokens": 18000, "temperature": 0.7}, + "complex": {"max_tokens": 20000, "temperature": 0.7}, } TIER_ORDER = ["trivial", "simple", "moderate", "complex"] @@ -249,26 +249,27 @@ def build_user_message(prompt: str, task_tier: str, is_modify: bool, block_conte # ─── LLM Helpers ────────────────────────────────────────────────── -def _call_llm(model: str, messages: list, params: dict, *, stream: bool = True): +def _call_llm(model: str, messages: list, params: dict, *, stream: bool = True, api_key: str | None = None): """Unified litellm call. Returns iterator (stream) or string (non-stream).""" import litellm + # Google AI Studio keys require the 'gemini/' provider prefix; + # without it litellm routes to Vertex AI and demands ADC credentials. + if model.startswith("gemini-"): + model = f"gemini/{model}" + litellm.drop_params = True - if stream: - return litellm.completion( - model=model, - messages=messages, - temperature=params["temperature"], - max_tokens=params["max_tokens"], - stream=True, - ) - resp = litellm.completion( + kwargs = dict( model=model, messages=messages, temperature=params["temperature"], max_tokens=params["max_tokens"], - stream=False, ) + if api_key: + kwargs["api_key"] = api_key + if stream: + return litellm.completion(**kwargs, stream=True) + resp = litellm.completion(**kwargs, stream=False) return resp.choices[0].message.content or "" @@ -292,7 +293,6 @@ def _parse_blocks(content: str) -> list[dict]: def get_available_models(): - """Return list of available AI models""" return [ { "provider": "openai", @@ -318,13 +318,9 @@ def get_available_models(): { "provider": "google", "models": [ - {"name": "gemini-1.5-flash", "label": "Gemini 1.5 Flash", "max_tokens": 1048576}, - {"name": "gemini-1.5-pro", "label": "Gemini 1.5 Pro", "max_tokens": 2097152}, - { - "name": "gemini-2.0-flash-exp", - "label": "Gemini 2.0 Flash (Experimental)", - "max_tokens": 1048576, - }, + {"name": "gemini-2.5-pro", "label": "Gemini 2.5 Pro", "max_tokens": 1048576}, + {"name": "gemini-2.5-flash", "label": "Gemini 2.5 Flash", "max_tokens": 1048576}, + {"name": "gemini-2.5-flash-lite", "label": "Gemini 2.5 Flash-Lite", "max_tokens": 1048576}, ], }, { @@ -411,8 +407,6 @@ def generate_page_blocks( params = TASK_PARAMS[task_tier] should_stream = task_tier in ("moderate", "complex") - set_api_key_for_provider(optimal_model, api_key) - tier_label = {"trivial": "quick", "simple": "fast", "moderate": "standard", "complex": "full"}[ task_tier ] @@ -437,7 +431,7 @@ def generate_page_blocks( # ── LLM call (stream for complex, single-shot for simple) ── if should_stream: - response = _call_llm(optimal_model, messages, params, stream=True) + response = _call_llm(optimal_model, messages, params, stream=True, api_key=api_key) for chunk in response: delta = chunk.choices[0].delta.content if delta: @@ -448,7 +442,7 @@ def generate_page_blocks( user=user, ) else: - content = _call_llm(optimal_model, messages, params, stream=False) + content = _call_llm(optimal_model, messages, params, stream=False, api_key=api_key) # ── Parse with automatic escalation on failure ── try: @@ -461,11 +455,12 @@ def generate_page_blocks( {"page_id": page_id, "message": f"Refining with {escalated}..."}, user=user, ) - set_api_key_for_provider(escalated, api_key) next_idx = min(TIER_ORDER.index(task_tier) + 1, len(TIER_ORDER) - 1) next_tier = TIER_ORDER[next_idx] messages[0]["content"] = get_system_prompt_for_tier(next_tier, is_modify=False) - content = _call_llm(escalated, messages, TASK_PARAMS[next_tier], stream=False) + content = _call_llm( + escalated, messages, TASK_PARAMS[next_tier], stream=False, api_key=api_key + ) blocks = _parse_blocks(content) else: raise @@ -534,7 +529,7 @@ def set_api_key_for_provider(model: str, api_key: str): key_mapping = { "openai": "OPENAI_API_KEY", "anthropic": "ANTHROPIC_API_KEY", - "google": "GOOGLE_API_KEY", + "google": "GEMINI_API_KEY", "x-ai": "XAI_API_KEY", } @@ -610,8 +605,6 @@ def modify_section_blocks( # Strip context to save tokens stripped_context = strip_block_context(block_context, task_tier) - set_api_key_for_provider(optimal_model, api_key) - tier_label = {"trivial": "quick", "simple": "fast", "moderate": "standard", "complex": "full"}[ task_tier ] @@ -636,7 +629,7 @@ def modify_section_blocks( # ── LLM call (stream for complex, single-shot for simple) ── if should_stream: - response = _call_llm(optimal_model, messages, params, stream=True) + response = _call_llm(optimal_model, messages, params, stream=True, api_key=api_key) for chunk in response: delta = chunk.choices[0].delta.content if delta: @@ -647,7 +640,7 @@ def modify_section_blocks( user=user, ) else: - content = _call_llm(optimal_model, messages, params, stream=False) + content = _call_llm(optimal_model, messages, params, stream=False, api_key=api_key) # ── Parse with automatic escalation on failure ── try: @@ -660,11 +653,12 @@ def modify_section_blocks( {"page_id": page_id, "message": f"Refining with {escalated}..."}, user=user, ) - set_api_key_for_provider(escalated, api_key) next_idx = min(TIER_ORDER.index(task_tier) + 1, len(TIER_ORDER) - 1) next_tier = TIER_ORDER[next_idx] messages[0]["content"] = get_system_prompt_for_tier(next_tier, is_modify=True) - content = _call_llm(escalated, messages, TASK_PARAMS[next_tier], stream=False) + content = _call_llm( + escalated, messages, TASK_PARAMS[next_tier], stream=False, api_key=api_key + ) blocks = _parse_blocks(content) else: raise @@ -694,43 +688,22 @@ def modify_section_blocks( @frappe.whitelist() def get_ai_models(): - """API endpoint to get available AI models""" return get_available_models() @frappe.whitelist() -def modify_section_from_prompt( - prompt: str, - block_context: str, - page_id: str | None = None, - model: str | None = None, - api_key: str | None = None, -): - """ - API endpoint to modify existing section blocks from a prompt. - Reads model and API key from Builder Settings if not provided. - """ +def modify_section_from_prompt(prompt: str, block_context: str, page_id: str | None = None): if not frappe.has_permission("Builder Page", ptype="write"): frappe.throw(_("You do not have permission to modify pages")) - if not prompt or not prompt.strip(): - frappe.throw(_("Please provide a prompt describing the modification")) - - if not block_context or not block_context.strip(): - frappe.throw(_("Block context is required for modification")) - - # Validate block_context is valid JSON try: json.loads(block_context) except json.JSONDecodeError: frappe.throw(_("Invalid block context JSON")) - # Read from Builder Settings if not provided - if not model: - settings = frappe.get_single("Builder Settings") - model = settings.get("ai_model") - if not api_key: - api_key = settings.get_password("ai_api_key", raise_exception=False) + settings = frappe.get_single("Builder Settings") + model = settings.get("ai_model") + api_key = settings.get_password("ai_api_key", raise_exception=False) if not model: frappe.throw(_("Please configure an AI model in Settings → AI")) @@ -756,25 +729,13 @@ def modify_section_from_prompt( @frappe.whitelist() -def generate_page_from_prompt( - prompt: str, page_id: str | None = None, model: str | None = None, api_key: str | None = None -): - """ - API endpoint to generate page blocks from a prompt. - Reads model and API key from Builder Settings if not provided. - """ +def generate_page_from_prompt(prompt: str, page_id: str | None = None): if not frappe.has_permission("Builder Page", ptype="write"): frappe.throw(_("You do not have permission to generate pages")) - if not prompt or not prompt.strip(): - frappe.throw(_("Please provide a prompt describing the page you want to create")) - - # Read from Builder Settings if not provided - if not model: - settings = frappe.get_single("Builder Settings") - model = settings.get("ai_model") - if not api_key: - api_key = settings.get_password("ai_api_key", raise_exception=False) + settings = frappe.get_single("Builder Settings") + model = settings.get("ai_model") + api_key = settings.get_password("ai_api_key", raise_exception=False) if not model: frappe.throw(_("Please configure an AI model in Settings → AI")) @@ -788,8 +749,6 @@ def generate_page_from_prompt( api_key=api_key, user=user, page_id=page_id, - queue="default", - is_async=True, ) return { @@ -804,13 +763,14 @@ def test_api_key(model: str, api_key: str): try: import litellm - set_api_key_for_provider(model, api_key) - # Make a simple test call + test_model = f"gemini/{model}" if model.startswith("gemini-") else model + litellm.drop_params = True litellm.completion( - model=model, + model=test_model, messages=[{"role": "user", "content": "Say 'OK' if you can read this"}], max_tokens=10, + api_key=api_key, ) return { From 8a84203ab3c2ca254e6b9b25d26097d95a00b088 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sat, 14 Mar 2026 18:30:22 +0530 Subject: [PATCH 014/287] fix: Misc --- builder/ai_page_generator.py | 8 ++--- .../src/components/AIPageGeneratorModal.vue | 29 +++++++++++-------- frontend/src/components/BuilderToolbar.vue | 16 +++++----- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index 11943c2bb..f706d222c 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -85,7 +85,7 @@ def classify_task(prompt: str, is_modify: bool = False) -> str: "gpt-5.4": 7, "claude-haiku-4-5": 1, "claude-sonnet-4-6": 4, - "claude-opus-4-6": 7, + # "claude-opus-4-6": 7, "gemini-2.5-flash-lite": 1, "gemini-2.5-flash": 2, "gemini-2.5-pro": 5, @@ -311,7 +311,7 @@ def get_available_models(): "provider": "anthropic", "models": [ {"name": "claude-sonnet-4-6", "label": "Claude Sonnet 4.6 (Latest)", "max_tokens": 200000}, - {"name": "claude-opus-4-6", "label": "Claude Opus 4.6 (Most Capable)", "max_tokens": 200000}, + # {"name": "claude-opus-4-6", "label": "Claude Opus 4.6 (Most Capable)", "max_tokens": 200000}, {"name": "claude-haiku-4-5", "label": "Claude Haiku 4.5 (Fastest)", "max_tokens": 200000}, ], }, @@ -378,7 +378,7 @@ def generate_page_blocks( content = "" try: try: - import litellm # noqa: F401 + import litellm except ImportError: frappe.publish_realtime( "ai_generation_error", @@ -573,7 +573,7 @@ def modify_section_blocks( content = "" try: try: - import litellm # noqa: F401 + import litellm except ImportError: frappe.publish_realtime( "ai_modify_error", diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index 68eb6b904..cc6df5f86 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -35,15 +35,19 @@
@@ -54,12 +58,13 @@
+ class="fixed left-1/2 top-16 z-[1000] -translate-x-1/2 transform">
-
+ name="loader" + class="h-4 w-4 animate-spin text-ink-gray-7" /> {{ progressMessage || (mode === "modify" ? "Modifying section..." : "Generating page...") }} diff --git a/frontend/src/components/BuilderToolbar.vue b/frontend/src/components/BuilderToolbar.vue index b7b70d986..4460109f3 100644 --- a/frontend/src/components/BuilderToolbar.vue +++ b/frontend/src/components/BuilderToolbar.vue @@ -93,6 +93,13 @@
Read Only
+ + + - @@ -166,6 +166,8 @@ import { useDark, useToggle } from "@vueuse/core"; import { Badge, Popover, Tooltip } from "frappe-ui"; import { computed, defineAsyncComponent, inject, ref } from "vue"; import { toast } from "vue-sonner"; +// @ts-ignore +import SparklesIcon from "~icons/lucide/sparkles"; import MainMenu from "./MainMenu.vue"; import PageOptions from "./PageOptions.vue"; From ec3aaee40c06d012dfa5df4f10923294c79eadfe Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sun, 15 Mar 2026 07:38:24 +0530 Subject: [PATCH 015/287] perf: Update prompts and parsing to use YAML format for block structures to save credits --- builder/ai_page_generator.py | 236 +++++++++++------- frontend/package.json | 2 + .../src/components/AIPageGeneratorModal.vue | 113 +++++---- frontend/src/utils/helpers.ts | 30 ++- yarn.lock | 16 +- 5 files changed, 247 insertions(+), 150 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index f706d222c..de73b4f43 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -1,5 +1,6 @@ import json import re +import yaml import frappe from frappe import _ @@ -144,38 +145,47 @@ def get_escalated_model(current_model: str, configured_model: str) -> str | None MINIMAL_MODIFY_PROMPT = ( "You modify web sections in Frappe Builder's block system.\n" - "Return ONLY valid JSON array. No markdown, no text. Start with [ end with ].\n\n" - 'Block: {"element":"tag","blockName":"n","blockId":"id","baseStyles":{...},' - '"children":[...],"attributes":{...},"innerText":"t","mobileStyles":{...},"tabletStyles":{...}}\n\n' - "Preserve all blockId values. Only change what was asked. Use camelCase CSS.\n" + "Return ONLY valid YAML array. No markdown, no text.\n\n" + "# Schema\n" + "el: str\n" + "id: str # MUST preserve\n" + "style?: dict\n" + "c?: [el]\n" + "text?: str\n\n" + "Preserve all id values. Only change what was asked. Use camelCase CSS.\n" "Wrap text in semantic elements (p, h1-h3, span, a) — never place text directly in div/section." ) FOCUSED_MODIFY_PROMPT = ( "You modify web sections in Frappe Builder's block system.\n" - "Return ONLY valid JSON array. No markdown, no explanations. Start with [ end with ].\n\n" - "Block props: element, blockName, blockId, baseStyles (CSS-in-JS camelCase), " - "mobileStyles, tabletStyles, attributes, innerText/innerHTML, children, classes.\n" - 'Style example: {"display":"flex","flexDirection":"column","padding":"2rem","backgroundColor":"#fff"}\n\n' - "Rules: Preserve blockId values. Only change what requested. Return COMPLETE block structure. " + "Return ONLY valid YAML array. No markdown, no explanations.\n\n" + "# Schema\n" + "el: str\n" + "id: str # MUST preserve existing\n" + "name?: str\n" + "style?: dict # CSS-in-JS camelCase\n" + "c?: [el]\n" + "attrs?: dict\n" + "text?: str\n" + "m_style?: dict\n\n" + "Rules: Preserve 'id' values. Only change what requested. Return COMPLETE structure. " "Use %, rem for responsive widths.\n" - "Wrap text in semantic elements (p, h1-h3, span, a) — never place text directly in div/section." + "Wrap text in semantic elements — never place text directly in div/section." ) COMPACT_GENERATION_PROMPT = ( "You generate web sections using Frappe Builder's block system.\n" - "Return ONLY valid JSON array. No markdown, no text. Start with [ end with ].\n\n" - 'Block: {"element":"section","blockName":"hero","baseStyles":{"display":"flex",' - '"flexDirection":"column","padding":"2rem"},"children":[...],"attributes":{},' - '"mobileStyles":{},"tabletStyles":{}}\n\n' - "Elements: section, div, nav, header, footer, h1-h3, p, span, button, a, img\n" - "Styles: CSS-in-JS camelCase \u2014 display, flexDirection, padding, margin, gap, " - "backgroundColor, color, fontSize, fontWeight, width, minHeight, background, " - "boxShadow, borderRadius, gridTemplateColumns\n" - "Attributes: src, alt, href, target | Text: innerText or innerHTML\n\n" + "Return ONLY valid YAML array. No markdown, no text.\n\n" + "# Schema\n" + "el: str # tag name (section, div, h1-h3, p, span, button, a, img, etc.)\n" + "name?: str # blockName\n" + "style?: dict # CSS-in-JS (camelCase: display, padding, gap, fontFamily, etc.)\n" + "c?: [el] # children\n" + "attrs?: dict # attributes (src, alt, href)\n" + "text?: str # inner content\n" + "m_style?: dict # mobile overrides\n\n" "Design: flex/grid layouts, 100% widths, modern colors, Google Fonts via fontFamily " - '(use ONLY the font name e.g. "Bebas Neue" — never add fallback families like cursive or sans-serif), ' - "responsive mobileStyles overrides.\n" + '(use ONLY the font name e.g. "Bebas Neue" — never add fallback families).\n' "Wrap text in semantic elements (p, h1-h3, span, a) — never place text directly in div/section." ) @@ -195,9 +205,40 @@ def get_system_prompt_for_tier(task_tier: str, is_modify: bool) -> str: # ─── Context Optimization ───────────────────────────────────────── +def compress_block_to_dsl(block: dict, depth: int=0, task_tier: str="moderate") -> dict: + if not isinstance(block, dict): + return block + dsl = {} + if block.get("element"): dsl["el"] = block["element"] + if block.get("blockId"): dsl["id"] = block["blockId"] + if block.get("blockName"): dsl["name"] = block["blockName"] + + if block.get("baseStyles") and isinstance(block.get("baseStyles"), dict): + dsl["style"] = block["baseStyles"] + if block.get("attributes") and isinstance(block.get("attributes"), dict): + dsl["attrs"] = block["attributes"] + if block.get("classes"): + dsl["classes"] = block["classes"] + + if block.get("innerText"): dsl["text"] = block["innerText"] + elif block.get("innerHTML"): dsl["text"] = block["innerHTML"] + + if task_tier != "trivial" or depth <= 1: + if block.get("mobileStyles") and isinstance(block.get("mobileStyles"), dict): + dsl["m_style"] = block["mobileStyles"] + if block.get("tabletStyles") and isinstance(block.get("tabletStyles"), dict): + dsl["t_style"] = block["tabletStyles"] + + children = block.get("children", []) + if children and isinstance(children, list): + compressed_children = [compress_block_to_dsl(c, depth + 1, task_tier) for c in children if isinstance(c, dict)] + if compressed_children: + dsl["c"] = compressed_children + + return dsl def strip_block_context(block_context: str, task_tier: str) -> str: - """Remove empty/default properties from block JSON to reduce input tokens.""" + """Convert block JSON to compact YAML using a terse DSL to reduce input tokens.""" try: data = json.loads(block_context) except (json.JSONDecodeError, TypeError): @@ -206,33 +247,37 @@ def strip_block_context(block_context: str, task_tier: str) -> str: if isinstance(data, dict): data = [data] - def _strip(block, depth=0): - if not isinstance(block, dict): - return block - out = {} - for k, v in block.items(): - if v is None: - continue - if isinstance(v, dict) and not v: - continue - if isinstance(v, list) and not v and k != "children": - continue - if isinstance(v, str) and not v and k not in ("innerText", "innerHTML"): - continue - if k == "children" and isinstance(v, list): - children = [_strip(c, depth + 1) for c in v if isinstance(c, dict)] - if children: - out[k] = children - else: - out[k] = v - if task_tier == "trivial" and depth > 1: - out.pop("mobileStyles", None) - out.pop("tabletStyles", None) - out.pop("classes", None) - return out - - stripped = [_strip(b) for b in data if isinstance(b, dict)] - return json.dumps(stripped, separators=(",", ":")) + stripped = [compress_block_to_dsl(b, 0, task_tier) for b in data if isinstance(b, dict)] + return yaml.dump(stripped, sort_keys=False, default_flow_style=False) + +def expand_dsl_to_block(dsl_block: dict) -> dict: + """Expand compact DSL dictionary back to Frappe Builder block schema.""" + if not isinstance(dsl_block, dict): + return dsl_block + block = { + "element": dsl_block.get("el", "div"), + "blockName": dsl_block.get("name", ""), + "baseStyles": dsl_block.get("style", {}), + "attributes": dsl_block.get("attrs", {}), + } + if "id" in dsl_block: + block["blockId"] = dsl_block["id"] + if "text" in dsl_block: + block["innerText"] = dsl_block["text"] + if "m_style" in dsl_block: + block["mobileStyles"] = dsl_block["m_style"] + if "t_style" in dsl_block: + block["tabletStyles"] = dsl_block["t_style"] + if "classes" in dsl_block: + block["classes"] = dsl_block["classes"] + + children = dsl_block.get("c", []) + if children and isinstance(children, list): + block["children"] = [expand_dsl_to_block(c) for c in children if isinstance(c, dict)] + else: + block["children"] = [] + + return block def build_user_message(prompt: str, task_tier: str, is_modify: bool, block_context: str | None = None) -> str: @@ -274,13 +319,21 @@ def _call_llm(model: str, messages: list, params: dict, *, stream: bool = True, def _parse_blocks(content: str) -> list[dict]: - """Parse LLM output into block list. Raises on invalid output.""" + """Parse LLM YAML output into block list and expand DSL. Raises on invalid output.""" content = content.strip() if content.startswith("```"): content = content.split("\n", 1)[1] if "\n" in content else content[3:] if content.endswith("```"): content = content[:-3].strip() - parsed = json.loads(content) + + if content.startswith("yaml\n"): + content = content[5:] + + try: + parsed = yaml.safe_load(content) + except Exception as e: + raise ValueError(f"YAML parsing failed: {e}") + if isinstance(parsed, dict): blocks = [parsed] elif isinstance(parsed, list): @@ -289,7 +342,8 @@ def _parse_blocks(content: str) -> list[dict]: raise ValueError("Not a valid block object") if not blocks: raise ValueError("No valid blocks in response") - return blocks + + return [expand_dsl_to_block(b) for b in blocks] def get_available_models(): @@ -338,32 +392,29 @@ def get_system_prompt(): """Full system prompt for complex page generation (moderate/complex tiers).""" return """You are an expert web designer specializing in creating modern, responsive web pages using the Frappe Builder block system. -Return ONLY a valid JSON array of blocks. No markdown, no explanations. Start with [ end with ]. - -# Block Structure: -- element: semantic HTML tag (section, div, nav, header, footer, h1-h3, p, span, button, a, img) -- blockName: descriptive name -- baseStyles: CSS-in-JS camelCase object (display, flexDirection, padding, margin, gap, backgroundColor, color, fontSize, fontWeight, width, minHeight, background, boxShadow, borderRadius, gridTemplateColumns, fontFamily) -- mobileStyles: mobile overrides (fontSize, padding, flexDirection, etc.) -- tabletStyles: tablet overrides -- attributes: HTML attrs (src, alt, href, target) -- innerText or innerHTML: text content -- children: nested blocks array +Return ONLY a valid YAML array of blocks. No markdown, no explanations. + +# Block Schema (Compact DSL): +- el: semantic HTML tag (section, div, nav, header, footer, h1-h3, p, span, button, a, img) +- name: descriptive name +- style: CSS-in-JS camelCase object (display, flexDirection, padding, margin, gap, backgroundColor, color, fontSize, fontWeight, width, minHeight, background, boxShadow, borderRadius, gridTemplateColumns, fontFamily) +- m_style: mobile overrides (fontSize, padding, flexDirection, etc.) +- t_style: tablet overrides +- attrs: HTML attrs (src, alt, href, target) +- text: text content +- c: nested blocks array - classes: CSS class names -# Style Format: -{"display":"flex","flexDirection":"column","padding":"2rem","backgroundColor":"#ffffff"} - # Rules: - Use flex/grid layouts with 100% widths - Modern harmonious color palettes -- Google Fonts via fontFamily in baseStyles (use ONLY the font name, e.g. "Bebas Neue" — do NOT add CSS fallback families like cursive, sans-serif, etc.) -- Responsive: %, rem, auto-fit units; add mobileStyles overrides +- Google Fonts via fontFamily in style (use ONLY the font name, e.g. "Bebas Neue" — do NOT add CSS fallback families) +- Responsive: %, rem, auto-fit units; add m_style overrides - Semantic HTML with alt texts for images - Consistent padding/margins -- Wrap text (innerText/innerHTML) in semantic text elements (p, h1-h3, span, a) — never place text directly in div/section. +- Wrap text in semantic text elements (p, h1-h3, span, a) — never place text directly in div/section. -# CRITICAL: Return ONLY valid JSON array. No markdown code blocks, no text outside the array.""" +# CRITICAL: Return ONLY valid YAML array.""" def generate_page_blocks( @@ -424,8 +475,13 @@ def generate_page_blocks( system_prompt = get_system_prompt_for_tier(task_tier, is_modify=False) user_message = build_user_message(prompt, task_tier, is_modify=False) + + sys_msg = {"role": "system", "content": system_prompt} + if get_provider_from_model(optimal_model) == "anthropic": + sys_msg["cache_control"] = {"type": "ephemeral"} + messages = [ - {"role": "system", "content": system_prompt}, + sys_msg, {"role": "user", "content": user_message}, ] @@ -447,7 +503,7 @@ def generate_page_blocks( # ── Parse with automatic escalation on failure ── try: blocks = _parse_blocks(content) - except (json.JSONDecodeError, ValueError): + except ValueError: escalated = get_escalated_model(optimal_model, model) if escalated and escalated != optimal_model: frappe.publish_realtime( @@ -471,11 +527,11 @@ def generate_page_blocks( user=user, ) - except json.JSONDecodeError as e: - frappe.log_error(f"JSON parse error: {e!s}\nContent: {content}", "AI Page Generation") + except ValueError as e: + frappe.log_error(f"Parse error: {e!s}\nContent: {content}", "AI Page Generation") frappe.publish_realtime( "ai_generation_error", - {"page_id": page_id, "message": "Failed to parse AI response. The model returned invalid JSON."}, + {"page_id": page_id, "message": "Failed to parse AI response. The model returned invalid YAML."}, user=user, ) @@ -542,22 +598,21 @@ def get_modify_system_prompt(): """Full system prompt for complex section modification (moderate/complex tiers).""" return """You are an expert web designer specializing in modifying web page sections using the Frappe Builder block system. -Modify the existing block structure based on user instructions. Return ONLY a valid JSON array of the modified blocks. No markdown, no explanations. +Modify the existing structure based on user instructions. Return ONLY a valid YAML array of the modified blocks. No markdown, no explanations. -# Block Structure: -- element, blockName, blockId, baseStyles (CSS-in-JS camelCase), mobileStyles, tabletStyles -- attributes (src, alt, href, target), innerText/innerHTML, children, classes +# Block Schema (Compact DSL): +- el, name, id (MUST PRESERVE), style (CSS-in-JS camelCase), m_style, t_style +- attrs (src, alt, href, target), text, c (children array), classes # Modification Rules: -1. Preserve ALL existing blockId values to prevent DOM churn +1. Preserve ALL existing 'id' values to prevent DOM churn 2. Only modify what the user explicitly asks for 3. Keep existing structure intact where not asked to change 4. Return the COMPLETE modified block structure (not a diff) 5. Use responsive units (%, rem, auto-fit) for widths -6. Style format: {"display":"flex","flexDirection":"column","padding":"2rem"} -7. Wrap text (innerText/innerHTML) in semantic text elements (p, h1-h3, span, a) — never place text directly in div/section +6. Wrap text in semantic text elements (p, h1-h3, span, a) — never place text directly in div/section -# CRITICAL: Return ONLY valid JSON array. Start with [ end with ].""" +# CRITICAL: Return ONLY valid YAML array.""" def modify_section_blocks( @@ -622,8 +677,13 @@ def modify_section_blocks( system_prompt = get_system_prompt_for_tier(task_tier, is_modify=True) user_message = build_user_message(prompt, task_tier, is_modify=True, block_context=stripped_context) + + sys_msg = {"role": "system", "content": system_prompt} + if get_provider_from_model(optimal_model) == "anthropic": + sys_msg["cache_control"] = {"type": "ephemeral"} + messages = [ - {"role": "system", "content": system_prompt}, + sys_msg, {"role": "user", "content": user_message}, ] @@ -645,7 +705,7 @@ def modify_section_blocks( # ── Parse with automatic escalation on failure ── try: blocks = _parse_blocks(content) - except (json.JSONDecodeError, ValueError): + except ValueError: escalated = get_escalated_model(optimal_model, model) if escalated and escalated != optimal_model: frappe.publish_realtime( @@ -669,11 +729,11 @@ def modify_section_blocks( user=user, ) - except json.JSONDecodeError as e: - frappe.log_error(f"JSON parse error: {e!s}\nContent: {content}", "AI Section Modify") + except ValueError as e: + frappe.log_error(f"Parse error: {e!s}\nContent: {content}", "AI Section Modify") frappe.publish_realtime( "ai_modify_error", - {"page_id": page_id, "message": "Failed to parse AI response. The model returned invalid JSON."}, + {"page_id": page_id, "message": "Failed to parse AI response. The model returned invalid YAML."}, user=user, ) diff --git a/frontend/package.json b/frontend/package.json index a9e91482c..05d37303c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "autoprefixer": "^10.4.2", "codemirror": "^6.0.2", "frappe-ui": "0.1.253", + "js-yaml": "^4.1.1", "opentype.js": "^1.3.4", "pinia": "^2.0.28", "postcss": "^8.4.5", @@ -53,6 +54,7 @@ }, "devDependencies": { "@tailwindcss/container-queries": "^0.1.1", + "@types/js-yaml": "^4.0.9", "@types/opentype.js": "^1.3.8", "cypress": "^13.3.2", "eslint": "^8.38.0", diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index cc6df5f86..6a71c3fa2 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -81,6 +81,7 @@ import { builderSettings } from "@/data/builderSettings"; import useBuilderStore from "@/stores/builderStore"; import { createResource, FeatherIcon, Textarea } from "frappe-ui"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; +import yaml from "js-yaml"; const props = withDefaults( defineProps<{ @@ -121,52 +122,59 @@ const hasAISettings = computed(() => { }); /** - * Attempt to repair partial/incomplete JSON by closing open strings, arrays, and objects. - * Returns a parseable JSON string or null if the content is too incomplete. + * Attempt to parse incomplete YAML stream content. + * Returns a JS object or null if too incomplete. */ -function repairPartialJSON(partial: string): string | null { - partial = partial.trim(); - if (!partial) return null; - - let result = partial; - let inString = false; - let escaped = false; - const stack: string[] = []; - - for (let i = 0; i < result.length; i++) { - const ch = result[i]; - if (escaped) { - escaped = false; - continue; - } - if (ch === "\\" && inString) { - escaped = true; - continue; - } - if (ch === '"') { - inString = !inString; - continue; - } - if (inString) continue; - if (ch === "{" || ch === "[") stack.push(ch); - else if (ch === "}") { - if (stack.length && stack[stack.length - 1] === "{") stack.pop(); - } else if (ch === "]") { - if (stack.length && stack[stack.length - 1] === "[") stack.pop(); - } +function expandDSL(dslBlock: any): any { + if (!dslBlock || typeof dslBlock !== "object" || Array.isArray(dslBlock)) return dslBlock; + + const ensureObj = (val: any) => (val && typeof val === "object" && !Array.isArray(val) ? val : {}); + const ensureArray = (val: any) => (Array.isArray(val) ? val : []); + + const block: any = { + element: dslBlock.el || "div", + blockName: dslBlock.name || "", + baseStyles: ensureObj(dslBlock.style), + attributes: ensureObj(dslBlock.attrs), + mobileStyles: ensureObj(dslBlock.m_style), + tabletStyles: ensureObj(dslBlock.t_style), + classes: ensureArray(dslBlock.classes), + }; + if (dslBlock.id) block.blockId = dslBlock.id; + if (dslBlock.text) block.innerText = dslBlock.text; + + if (Array.isArray(dslBlock.c)) { + block.children = dslBlock.c.map(expandDSL); + } else { + block.children = []; } + return block; +} - if (inString) { - result += '"'; +function getValidPartialYAML(yamlStr: string): any { + let cleaned = yamlStr.trim(); + if (cleaned.startsWith("```")) { + const lines = cleaned.split("\n"); + if (lines.length > 0) lines.shift(); // remove ```yaml or ``` + if (lines.length > 0 && lines[lines.length - 1].startsWith("```")) { + lines.pop(); + } + cleaned = lines.join("\n"); } - result = result.replace(/[,:\s]+$/, ""); - - for (let i = stack.length - 1; i >= 0; i--) { - result += stack[i] === "{" ? "}" : "]"; + try { + return yaml.load(cleaned); + } catch (e) { + const lines = cleaned.split("\n"); + for (let i = lines.length - 1; i > 0; i--) { + try { + const slice = lines.slice(0, i).join("\n"); + const parsed = yaml.load(slice); + if (parsed) return parsed; + } catch (err) {} + } } - - return result; + return null; } const canGenerate = computed(() => { @@ -255,13 +263,12 @@ const onStream = (data: any) => { if (data.page_id && data.page_id !== props.pageId) return; if (data.chunk) { streamingContent.value += data.chunk; - // Try to repair partial JSON and render live - const repaired = repairPartialJSON(streamingContent.value); - if (repaired) { + const parsed = getValidPartialYAML(streamingContent.value); + if (parsed) { try { - let parsed = JSON.parse(repaired); - let sections = Array.isArray(parsed) ? parsed : [parsed]; - sections = sections.filter((s: any) => s && typeof s === "object" && s.element); + let parsedArray = Array.isArray(parsed) ? parsed : [parsed]; + let sections = parsedArray.filter((s: any) => s && typeof s === "object" && s.el); + sections = sections.map(expandDSL); if (sections.length > 0) { const wrapped = [ { @@ -284,7 +291,7 @@ const onStream = (data: any) => { emit("streaming", wrapped); } } catch { - // Still not valid enough, wait for more chunks + // Ignore errors in partial parse } } } @@ -334,17 +341,17 @@ const onModifyStream = (data: any) => { if (data.page_id && data.page_id !== props.pageId) return; if (data.chunk) { streamingContent.value += data.chunk; - const repaired = repairPartialJSON(streamingContent.value); - if (repaired) { + const parsed = getValidPartialYAML(streamingContent.value); + if (parsed) { try { - let parsed = JSON.parse(repaired); - let sections = Array.isArray(parsed) ? parsed : [parsed]; - sections = sections.filter((s: any) => s && typeof s === "object" && s.element); + let parsedArray = Array.isArray(parsed) ? parsed : [parsed]; + let sections = parsedArray.filter((s: any) => s && typeof s === "object" && s.el); + sections = sections.map(expandDSL); if (sections.length > 0) { emit("modifyStreaming", sections); } } catch { - // Still not valid enough, wait for more chunks + // Ignore } } } diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 214475be0..5a50081d2 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -465,17 +465,33 @@ async function uploadBuilderAsset(file: File, silent = false) { function dataURLtoFile(dataurl: string, filename: string) { try { - let arr = dataurl.split(","), - mime = arr[0].match(/:(.*?);/)?.[1], - bstr = atob(arr[1]), - n = bstr.length, + let arr = dataurl.split(","); + let mimeMatch = arr[0].match(/:(.*?)(;|,)/); + let mime = mimeMatch ? mimeMatch[1] : ""; + let isBase64 = arr[0].includes(";base64"); + + let dataString = arr.slice(1).join(","); + let u8arr; + + if (isBase64) { + let bstr = atob(dataString); + let n = bstr.length; u8arr = new Uint8Array(n); - while (n--) { - u8arr[n] = bstr.charCodeAt(n); + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + } else { + let decoded = decodeURIComponent(dataString); + let n = decoded.length; + u8arr = new Uint8Array(n); + while (n--) { + u8arr[n] = decoded.charCodeAt(n); + } } + return new File([u8arr], filename, { type: mime }); } catch (error) { - console.error(`Failed to convert dataURL ${dataurl} to file.`); + console.error(`Failed to convert dataURL ${dataurl.substring(0, 50)}... to file.`, error); return null; } } diff --git a/yarn.lock b/yarn.lock index 0bcf6511a..29affa28c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1445,6 +1445,11 @@ dependencies: "@types/unist" "*" +"@types/js-yaml@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" + integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" @@ -4407,6 +4412,13 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + jsbn@~0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" @@ -4562,7 +4574,7 @@ linkifyjs@^4.2.0, linkifyjs@^4.3.2: resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1" integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA== -lint-staged@>=10, lint-staged@^15.2.10: +lint-staged@>=10: version "15.5.2" resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-15.5.2.tgz#beff028fd0681f7db26ffbb67050a21ed4d059a3" integrity sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w== @@ -7318,4 +7330,4 @@ zrender@5.6.1: resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.6.1.tgz#e08d57ecf4acac708c4fcb7481eb201df7f10a6b" integrity sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag== dependencies: - tslib "2.3.0" \ No newline at end of file + tslib "2.3.0" From 4b4a05df6d97d0beb0905a3437a37716c94ace95 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sun, 15 Mar 2026 08:24:35 +0530 Subject: [PATCH 016/287] refactor: Update regex patterns to use re.compile for better performance and readability --- builder/ai_page_generator.py | 250 +++++++++++++++++++++++------------ 1 file changed, 166 insertions(+), 84 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index de73b4f43..a51eb8b5f 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -1,31 +1,35 @@ import json import re -import yaml import frappe +import yaml from frappe import _ # ─── Task Classification ─────────────────────────────────────────── TRIVIAL_PATTERNS = [ - r"\b(change|update|rename|rephrase|reword|fix|correct|replace)\b.*\b(text|title|heading|label|typo|word|name|copy|content|string)\b", - r"\b(change|update|set|make)\b.*\b(colou?r|font|size|bold|italic|underline|weight)\b", - r"\b(change|update|set)\b.*\b(background|bg)\s*(colou?r)?\b", - r"\bmake\s+(it\s+)?(bigger|smaller|larger|bolder|lighter|darker|brighter)\b", + re.compile( + r"\b(change|update|rename|rephrase|reword|fix|correct|replace)\b.*\b(text|title|heading|label|typo|word|name|copy|content|string)\b" + ), + re.compile(r"\b(change|update|set|make)\b.*\b(colou?r|font|size|bold|italic|underline|weight)\b"), + re.compile(r"\b(change|update|set)\b.*\b(background|bg)\s*(colou?r)?\b"), + re.compile(r"\bmake\s+(it\s+)?(bigger|smaller|larger|bolder|lighter|darker|brighter)\b"), ] SIMPLE_PATTERNS = [ - r"\b(add|insert|include|put)\b.*\b(button|link|icon|image|img|divider|spacer|badge|tag)\b", - r"\b(change|update|set|adjust|modify|tweak)\b.*\b(padding|margin|spacing|gap|border|shadow|radius|opacity|alignment|align|width|height)\b", - r"\b(center|left.?align|right.?align|justify)\b", - r"\b(hide|show|remove|delete|toggle)\b.*\b(block|element|section|button|text|image|div)\b", - r"\b(move|swap|reorder|rearrange)\b", - r"\b(round|rounded|square|circle|pill)\b.*\b(corner|border|shape)\b", + re.compile(r"\b(add|insert|include|put)\b.*\b(button|link|icon|image|img|divider|spacer|badge|tag)\b"), + re.compile( + r"\b(change|update|set|adjust|modify|tweak)\b.*\b(padding|margin|spacing|gap|border|shadow|radius|opacity|alignment|align|width|height)\b" + ), + re.compile(r"\b(center|left.?align|right.?align|justify)\b"), + re.compile(r"\b(hide|show|remove|delete|toggle)\b.*\b(block|element|section|button|text|image|div)\b"), + re.compile(r"\b(move|swap|reorder|rearrange)\b"), + re.compile(r"\b(round|rounded|square|circle|pill)\b.*\b(corner|border|shape)\b"), ] COMPLEX_PATTERNS = [ - r"\b(create|build|generate|make|design)\b.*\b(page|landing|website|full|complete)\b", - r"\b(from\s+scratch|entire|whole|complete|multi.?section)\b", + re.compile(r"\b(create|build|generate|make|design)\b.*\b(page|landing|website|full|complete)\b"), + re.compile(r"\b(from\s+scratch|entire|whole|complete|multi.?section)\b"), ] @@ -35,18 +39,18 @@ def classify_task(prompt: str, is_modify: bool = False) -> str: if not is_modify: for p in COMPLEX_PATTERNS: - if re.search(p, lower): + if p.search(lower): return "complex" return "moderate" for p in TRIVIAL_PATTERNS: - if re.search(p, lower): + if p.search(lower): return "trivial" for p in SIMPLE_PATTERNS: - if re.search(p, lower): + if p.search(lower): return "simple" for p in COMPLEX_PATTERNS: - if re.search(p, lower): + if p.search(lower): return "complex" return "moderate" @@ -121,24 +125,29 @@ def get_optimal_model(configured_model: str, task_tier: str) -> str: def get_escalated_model(current_model: str, configured_model: str) -> str | None: """Next-tier fallback model, capped at configured model's cost.""" - provider = get_provider_from_model(current_model) + provider = get_provider_from_model(configured_model) tier_map = MODEL_TIERS.get(provider) if not tier_map: return None - reverse_map = {v: k for k, v in tier_map.items()} - current_tier = reverse_map.get(current_model) - if not current_tier or current_tier not in TIER_ORDER: - return None - idx = TIER_ORDER.index(current_tier) - if idx >= len(TIER_ORDER) - 1: - return None - next_model = tier_map.get(TIER_ORDER[idx + 1]) - if not next_model or next_model == current_model: - return None + configured_cost = MODEL_COST_INDEX.get(configured_model, 5) - if MODEL_COST_INDEX.get(next_model, 5) > configured_cost: - return configured_model if configured_model != current_model else None - return next_model + + current_tier = None + for tier in TIER_ORDER: + if tier_map.get(tier) == current_model: + current_tier = tier + + if current_tier is None or current_tier not in TIER_ORDER: + return None + + for next_tier in TIER_ORDER[TIER_ORDER.index(current_tier) + 1 :]: + next_model = tier_map.get(next_tier) + if not next_model or next_model == current_model: + continue + if MODEL_COST_INDEX.get(next_model, 5) <= configured_cost: + return next_model + + return None # ─── Tiered System Prompts ──────────────────────────────────────── @@ -205,38 +214,100 @@ def get_system_prompt_for_tier(task_tier: str, is_modify: bool) -> str: # ─── Context Optimization ───────────────────────────────────────── -def compress_block_to_dsl(block: dict, depth: int=0, task_tier: str="moderate") -> dict: +STYLE_KEYS_TRIVIAL = frozenset( + { + "color", + "backgroundColor", + "fontSize", + "fontWeight", + "fontStyle", + "textDecoration", + "opacity", + "visibility", + } +) + +STYLE_KEYS_SIMPLE = frozenset( + { + "color", + "backgroundColor", + "background", + "fontSize", + "fontWeight", + "fontStyle", + "textDecoration", + "opacity", + "visibility", + "padding", + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "margin", + "marginTop", + "marginRight", + "marginBottom", + "marginLeft", + "border", + "borderRadius", + "borderColor", + "borderWidth", + "display", + "width", + "height", + "maxWidth", + } +) + + +def compress_block_to_dsl(block: dict, depth: int = 0, task_tier: str = "moderate") -> dict: if not isinstance(block, dict): return block dsl = {} - if block.get("element"): dsl["el"] = block["element"] - if block.get("blockId"): dsl["id"] = block["blockId"] - if block.get("blockName"): dsl["name"] = block["blockName"] - - if block.get("baseStyles") and isinstance(block.get("baseStyles"), dict): - dsl["style"] = block["baseStyles"] - if block.get("attributes") and isinstance(block.get("attributes"), dict): + if block.get("element"): + dsl["el"] = block["element"] + if block.get("blockId"): + dsl["id"] = block["blockId"] + if block.get("blockName"): + dsl["name"] = block["blockName"] + + if block.get("baseStyles") and isinstance(block.get("baseStyles"), dict): + if task_tier == "trivial": + filtered = {k: v for k, v in block["baseStyles"].items() if k in STYLE_KEYS_TRIVIAL} + elif task_tier == "simple": + filtered = {k: v for k, v in block["baseStyles"].items() if k in STYLE_KEYS_SIMPLE} + else: + filtered = block["baseStyles"] + if filtered: + dsl["style"] = filtered + + if block.get("attributes") and isinstance(block.get("attributes"), dict): dsl["attrs"] = block["attributes"] - if block.get("classes"): + if block.get("classes"): dsl["classes"] = block["classes"] - - if block.get("innerText"): dsl["text"] = block["innerText"] - elif block.get("innerHTML"): dsl["text"] = block["innerHTML"] - + + if block.get("innerText"): + dsl["text"] = block["innerText"] + elif block.get("innerHTML"): + dsl["text"] = block["innerHTML"] + if task_tier != "trivial" or depth <= 1: - if block.get("mobileStyles") and isinstance(block.get("mobileStyles"), dict): + if block.get("mobileStyles") and isinstance(block.get("mobileStyles"), dict): dsl["m_style"] = block["mobileStyles"] - if block.get("tabletStyles") and isinstance(block.get("tabletStyles"), dict): + if block.get("tabletStyles") and isinstance(block.get("tabletStyles"), dict): dsl["t_style"] = block["tabletStyles"] - + children = block.get("children", []) if children and isinstance(children, list): - compressed_children = [compress_block_to_dsl(c, depth + 1, task_tier) for c in children if isinstance(c, dict)] + compressed_children = [ + compress_block_to_dsl(c, depth + 1, task_tier) for c in children if isinstance(c, dict) + ] if compressed_children: dsl["c"] = compressed_children - + return dsl + def strip_block_context(block_context: str, task_tier: str) -> str: """Convert block JSON to compact YAML using a terse DSL to reduce input tokens.""" try: @@ -250,6 +321,7 @@ def strip_block_context(block_context: str, task_tier: str) -> str: stripped = [compress_block_to_dsl(b, 0, task_tier) for b in data if isinstance(b, dict)] return yaml.dump(stripped, sort_keys=False, default_flow_style=False) + def expand_dsl_to_block(dsl_block: dict) -> dict: """Expand compact DSL dictionary back to Frappe Builder block schema.""" if not isinstance(dsl_block, dict): @@ -270,13 +342,13 @@ def expand_dsl_to_block(dsl_block: dict) -> dict: block["tabletStyles"] = dsl_block["t_style"] if "classes" in dsl_block: block["classes"] = dsl_block["classes"] - + children = dsl_block.get("c", []) if children and isinstance(children, list): block["children"] = [expand_dsl_to_block(c) for c in children if isinstance(c, dict)] else: block["children"] = [] - + return block @@ -298,23 +370,52 @@ def _call_llm(model: str, messages: list, params: dict, *, stream: bool = True, """Unified litellm call. Returns iterator (stream) or string (non-stream).""" import litellm - # Google AI Studio keys require the 'gemini/' provider prefix; - # without it litellm routes to Vertex AI and demands ADC credentials. if model.startswith("gemini-"): model = f"gemini/{model}" litellm.drop_params = True + + # For Anthropic: inject cache_control into the system message content block + # so litellm forwards it correctly to Anthropic's caching layer. + # For all other providers: pass messages unchanged. + if "claude-" in model: + patched = [] + for m in messages: + if m["role"] == "system": + patched.append( + { + "role": "system", + "content": [ + { + "type": "text", + "text": m["content"] + if isinstance(m["content"], str) + else m["content"][0].get("text", ""), + } + ], + "cache_control": {"type": "ephemeral"}, + } + ) + else: + patched.append(m) + messages = patched + kwargs = dict( model=model, messages=messages, temperature=params["temperature"], max_tokens=params["max_tokens"], ) + if api_key: kwargs["api_key"] = api_key if stream: - return litellm.completion(**kwargs, stream=True) - resp = litellm.completion(**kwargs, stream=False) + kwargs["stream"] = True + + if stream: + return litellm.completion(**kwargs) + + resp = litellm.completion(**kwargs) return resp.choices[0].message.content or "" @@ -322,18 +423,15 @@ def _parse_blocks(content: str) -> list[dict]: """Parse LLM YAML output into block list and expand DSL. Raises on invalid output.""" content = content.strip() if content.startswith("```"): - content = content.split("\n", 1)[1] if "\n" in content else content[3:] - if content.endswith("```"): - content = content[:-3].strip() - - if content.startswith("yaml\n"): - content = content[5:] - + content = re.sub(r"^```(?:yaml)?\s*\n?", "", content) + content = re.sub(r"\n?```\s*$", "", content) + content = content.strip() + try: parsed = yaml.safe_load(content) except Exception as e: raise ValueError(f"YAML parsing failed: {e}") - + if isinstance(parsed, dict): blocks = [parsed] elif isinstance(parsed, list): @@ -342,7 +440,7 @@ def _parse_blocks(content: str) -> list[dict]: raise ValueError("Not a valid block object") if not blocks: raise ValueError("No valid blocks in response") - + return [expand_dsl_to_block(b) for b in blocks] @@ -452,7 +550,6 @@ def generate_page_blocks( ) return - # ── Smart routing ── task_tier = classify_task(prompt, is_modify=False) optimal_model = get_optimal_model(model, task_tier) params = TASK_PARAMS[task_tier] @@ -475,17 +572,12 @@ def generate_page_blocks( system_prompt = get_system_prompt_for_tier(task_tier, is_modify=False) user_message = build_user_message(prompt, task_tier, is_modify=False) - - sys_msg = {"role": "system", "content": system_prompt} - if get_provider_from_model(optimal_model) == "anthropic": - sys_msg["cache_control"] = {"type": "ephemeral"} - + messages = [ - sys_msg, + {"role": "system", "content": system_prompt, "cache_control": {"type": "ephemeral"}}, {"role": "user", "content": user_message}, ] - # ── LLM call (stream for complex, single-shot for simple) ── if should_stream: response = _call_llm(optimal_model, messages, params, stream=True, api_key=api_key) for chunk in response: @@ -500,7 +592,6 @@ def generate_page_blocks( else: content = _call_llm(optimal_model, messages, params, stream=False, api_key=api_key) - # ── Parse with automatic escalation on failure ── try: blocks = _parse_blocks(content) except ValueError: @@ -651,13 +742,11 @@ def modify_section_blocks( ) return - # ── Smart routing ── task_tier = classify_task(prompt, is_modify=True) optimal_model = get_optimal_model(model, task_tier) params = TASK_PARAMS[task_tier] should_stream = task_tier in ("moderate", "complex") - # Strip context to save tokens stripped_context = strip_block_context(block_context, task_tier) tier_label = {"trivial": "quick", "simple": "fast", "moderate": "standard", "complex": "full"}[ @@ -677,17 +766,12 @@ def modify_section_blocks( system_prompt = get_system_prompt_for_tier(task_tier, is_modify=True) user_message = build_user_message(prompt, task_tier, is_modify=True, block_context=stripped_context) - - sys_msg = {"role": "system", "content": system_prompt} - if get_provider_from_model(optimal_model) == "anthropic": - sys_msg["cache_control"] = {"type": "ephemeral"} - + messages = [ - sys_msg, + {"role": "system", "content": system_prompt, "cache_control": {"type": "ephemeral"}}, {"role": "user", "content": user_message}, ] - # ── LLM call (stream for complex, single-shot for simple) ── if should_stream: response = _call_llm(optimal_model, messages, params, stream=True, api_key=api_key) for chunk in response: @@ -702,7 +786,6 @@ def modify_section_blocks( else: content = _call_llm(optimal_model, messages, params, stream=False, api_key=api_key) - # ── Parse with automatic escalation on failure ── try: blocks = _parse_blocks(content) except ValueError: @@ -823,7 +906,6 @@ def test_api_key(model: str, api_key: str): try: import litellm - # Make a simple test call test_model = f"gemini/{model}" if model.startswith("gemini-") else model litellm.drop_params = True litellm.completion( From 3cdf6a17f2dacc5e677b912a1a5ba08399704375 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sun, 15 Mar 2026 08:24:46 +0530 Subject: [PATCH 017/287] fix: Ensure font weights are correctly handled in font mapping --- builder/builder/doctype/builder_page/builder_page.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/builder/builder/doctype/builder_page/builder_page.py b/builder/builder/doctype/builder_page/builder_page.py index 78c1e6644..f5c9260e1 100644 --- a/builder/builder/doctype/builder_page/builder_page.py +++ b/builder/builder/doctype/builder_page/builder_page.py @@ -1144,12 +1144,13 @@ def set_fonts(styles, font_map): if font: # escape spaces in font name style["fontFamily"] = font.replace(" ", "\\ ") + weight = str(style.get("fontWeight") or "400") if font in font_map: - if style.get("fontWeight") and style.get("fontWeight") not in font_map[font]["weights"]: - font_map[font]["weights"].append(style.get("fontWeight")) + if weight not in font_map[font]["weights"]: + font_map[font]["weights"].append(weight) font_map[font]["weights"].sort() else: - font_map[font] = {"weights": [style.get("fontWeight") or "400"]} + font_map[font] = {"weights": [weight]} def set_fonts_from_html(soup, font_map): From 40ab8bdb35f5a01a9d276fd01c91da5c2887abdd Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sun, 15 Mar 2026 13:50:41 +0530 Subject: [PATCH 018/287] fix: Thorttle block streaming --- frontend/src/pages/PageBuilder.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/PageBuilder.vue b/frontend/src/pages/PageBuilder.vue index 822043f3d..bd48b89ea 100644 --- a/frontend/src/pages/PageBuilder.vue +++ b/frontend/src/pages/PageBuilder.vue @@ -136,6 +136,7 @@ import { useActiveElement, useBreakpoints, useDebounceFn, + useThrottleFn, useMagicKeys, } from "@vueuse/core"; import { createResource } from "frappe-ui"; @@ -209,7 +210,7 @@ const handleGeneratedBlocks = (blocks: any[]) => { }; // Handle live streaming blocks (partial, not saved) -const handleStreamingBlocks = (blocks: any[]) => { +const handleStreamingBlocks = useThrottleFn((blocks: any[]) => { if (!blocks || blocks.length === 0) return; try { @@ -218,7 +219,7 @@ const handleStreamingBlocks = (blocks: any[]) => { } catch { // Partial block may still be invalid, skip this frame } -}; +}, 60); // Find a block in the tree by blockId and replace its children/styles with new data const replaceBlockInTree = (root: any, targetId: string, newBlocks: any[]): boolean => { @@ -272,7 +273,7 @@ const handleModifiedBlocks = (blocks: any[]) => { }; // Handle live streaming of modify (update block in-place) -const handleModifyStreamingBlocks = (blocks: any[]) => { +const handleModifyStreamingBlocks = useThrottleFn((blocks: any[]) => { if (!blocks || blocks.length === 0 || !modifyBlockId.value) return; try { @@ -283,7 +284,7 @@ const handleModifyStreamingBlocks = (blocks: any[]) => { } catch { // Partial block may still be invalid, skip this frame } -}; +}, 60); watch([() => canvasStore.editableBlock, () => pageStore.activePage?.is_standard], () => { builderStore.toggleReadOnlyMode( From a6b31deee4f3c3698eca538eab2e73f17ebafef5 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Sun, 15 Mar 2026 13:51:45 +0530 Subject: [PATCH 019/287] fix: Force full-width requirement for top-level sections in prompts --- builder/ai_page_generator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index a51eb8b5f..a12c4b0fe 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -178,7 +178,7 @@ def get_escalated_model(current_model: str, configured_model: str) -> str | None "text?: str\n" "m_style?: dict\n\n" "Rules: Preserve 'id' values. Only change what requested. Return COMPLETE structure. " - "Use %, rem for responsive widths.\n" + "Use %, rem for responsive widths. Top-level sections MUST be 100% width.\n" "Wrap text in semantic elements — never place text directly in div/section." ) @@ -193,7 +193,7 @@ def get_escalated_model(current_model: str, configured_model: str) -> str | None "attrs?: dict # attributes (src, alt, href)\n" "text?: str # inner content\n" "m_style?: dict # mobile overrides\n\n" - "Design: flex/grid layouts, 100% widths, modern colors, Google Fonts via fontFamily " + "Design: flex/grid layouts, top-level sections MUST be 100% width, modern colors, Google Fonts via fontFamily " '(use ONLY the font name e.g. "Bebas Neue" — never add fallback families).\n' "Wrap text in semantic elements (p, h1-h3, span, a) — never place text directly in div/section." ) @@ -504,7 +504,7 @@ def get_system_prompt(): - classes: CSS class names # Rules: -- Use flex/grid layouts with 100% widths +- Top-level sections MUST always have 'width: 100%'. Use flex/grid layouts. - Modern harmonious color palettes - Google Fonts via fontFamily in style (use ONLY the font name, e.g. "Bebas Neue" — do NOT add CSS fallback families) - Responsive: %, rem, auto-fit units; add m_style overrides @@ -700,7 +700,7 @@ def get_modify_system_prompt(): 2. Only modify what the user explicitly asks for 3. Keep existing structure intact where not asked to change 4. Return the COMPLETE modified block structure (not a diff) -5. Use responsive units (%, rem, auto-fit) for widths +5. Use responsive units (%, rem, auto-fit) for widths. Top-level sections MUST be 100% width. 6. Wrap text in semantic text elements (p, h1-h3, span, a) — never place text directly in div/section # CRITICAL: Return ONLY valid YAML array.""" From 167fdbf5ac43d7a3826d6b8bc6272e30f6559aa4 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 16 Mar 2026 22:48:20 +0530 Subject: [PATCH 020/287] feat: Shortcut to open edit with AI modal --- frontend/src/pages/PageBuilder.vue | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/PageBuilder.vue b/frontend/src/pages/PageBuilder.vue index c8014492a..92e0f1dd9 100644 --- a/frontend/src/pages/PageBuilder.vue +++ b/frontend/src/pages/PageBuilder.vue @@ -114,6 +114,7 @@ From 5a80e592f2d4e2b7f3fc4cceae338587b88b6d2e Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Mar 2026 12:11:53 +0530 Subject: [PATCH 025/287] feat: Add prompts for text rewriting and image replacement --- builder/ai_page_generator.py | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py index 810d294af..ba8d2dae5 100644 --- a/builder/ai_page_generator.py +++ b/builder/ai_page_generator.py @@ -6,9 +6,13 @@ from frappe import _ -def classify_task(prompt: str, is_modify: bool = False) -> str: - """Classify task → simple (modify) | complex (full page creation).""" - return "simple" if is_modify else "complex" +def classify_task(prompt: str, is_modify: bool = False, task_type: str | None = None) -> str: + """Classify task → simple (specific modify) | complex (general modify/full page).""" + if not is_modify: + return "complex" + if task_type in ["rewrite_text", "replace_image"]: + return "simple" + return "complex" # ─── Model Selection & Cost Intelligence ────────────────────────── @@ -104,9 +108,25 @@ def get_escalated_model(current_model: str, configured_model: str) -> str | None "Wrap text in semantic elements — never place text directly in div/section." ) +REWRITE_TEXT_PROMPT = ( + "You are a professional copywriter. Rewrite the text content in the provided block structure to be more engaging and professional.\n" + "Return ONLY a valid YAML array. Preserve everything. Only update the 'text' property.\n" + "No markdown, no explanations." +) + +REPLACE_IMAGE_PROMPT = ( + "You are a visual design assistant. Suggest a highly relevant, high-quality image description or URL for the provided block.\n" + "Return ONLY a valid YAML array. Preserve everything. Only update 'attrs' property like 'src' and 'alt'.\n" + "No markdown, no explanations." +) -def get_system_prompt_for_tier(task_tier: str, is_modify: bool) -> str: + +def get_system_prompt_for_tier(task_tier: str, is_modify: bool, task_type: str | None = None) -> str: if is_modify: + if task_type == "rewrite_text": + return REWRITE_TEXT_PROMPT + if task_type == "replace_image": + return REPLACE_IMAGE_PROMPT return MODIFY_PROMPT return get_system_prompt() @@ -363,7 +383,7 @@ def get_available_models(): def get_system_prompt(): - """Full system prompt for complex page generation (moderate/complex tiers).""" + """Full system prompt for complex page generation.""" return """You are an expert web designer specializing in creating modern, responsive web pages using the Frappe Builder block system. Return ONLY a valid YAML array of blocks. No markdown, no explanations. @@ -566,6 +586,7 @@ def modify_section_blocks( api_key: str | None = None, user: str | None = None, page_id: str | None = None, + task_type: str | None = None, ): """Smart section modification with context stripping and auto model selection.""" user = user or frappe.session.user @@ -595,7 +616,7 @@ def modify_section_blocks( ) return - task_tier = classify_task(prompt, is_modify=True) + task_tier = classify_task(prompt, is_modify=True, task_type=task_type) optimal_model = get_optimal_model(model, task_tier) params = TASK_PARAMS[task_tier] should_stream = True @@ -614,7 +635,7 @@ def modify_section_blocks( user=user, ) - system_prompt = get_system_prompt_for_tier(task_tier, is_modify=True) + system_prompt = get_system_prompt_for_tier(task_tier, is_modify=True, task_type=task_type) user_message = build_user_message(prompt, task_tier, is_modify=True, block_context=stripped_context) messages = [ @@ -685,7 +706,9 @@ def get_ai_models(): @frappe.whitelist() -def modify_section_from_prompt(prompt: str, block_context: str, page_id: str | None = None): +def modify_section_from_prompt( + prompt: str, block_context: str, page_id: str | None = None, task_type: str | None = None +): if not frappe.has_permission("Builder Page", ptype="write"): frappe.throw(_("You do not have permission to modify pages")) @@ -713,6 +736,7 @@ def modify_section_from_prompt(prompt: str, block_context: str, page_id: str | N page_id=page_id, queue="default", is_async=True, + task_type=task_type, ) return { From e1208b6791898f9c91cdbc7ff0bf3830acf6f083 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Thu, 19 Mar 2026 12:14:47 +0530 Subject: [PATCH 026/287] feat: Direct editing options for text and images --- .../src/components/AIPageGeneratorModal.vue | 79 ++++++++++++++++--- frontend/src/components/BlockContextMenu.vue | 33 ++++++++ frontend/src/pages/PageBuilder.vue | 11 ++- 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/AIPageGeneratorModal.vue b/frontend/src/components/AIPageGeneratorModal.vue index 768f7c834..02e1c2737 100644 --- a/frontend/src/components/AIPageGeneratorModal.vue +++ b/frontend/src/components/AIPageGeneratorModal.vue @@ -2,7 +2,7 @@ diff --git a/frontend/src/components/WebPagePresetPicker.vue b/frontend/src/components/WebPagePresetPicker.vue index fc40e81f3..30c2b0138 100644 --- a/frontend/src/components/WebPagePresetPicker.vue +++ b/frontend/src/components/WebPagePresetPicker.vue @@ -50,22 +50,6 @@
- - - - - - -