diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 71d3588f2..39075cff7 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1 +1,2 @@ b3ddeda50bf11d04ee3b82f38af7355aad006fe0 +fad996a6275cd1fab5e62e930219fc2d9ccbc75c diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..160740ee5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..e301d68ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: feature request +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 000000000..2c7373563 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,65 @@ +# When updating this file, please also update the linter_workflow_template in frappe/utils/boilerplate.py +name: Linters + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.number }} + cancel-in-progress: true + +jobs: + linter: + name: 'Semgrep Rules' + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + + - name: Download Semgrep rules + run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules + + - name: Run Semgrep rules + run: | + pip install semgrep + semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness + + deps-vulnerable-check: + name: 'Vulnerable Dependency Check' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + + - name: Install and run pip-audit + run: | + pip install pip-audit + pip-audit --desc on --ignore-vuln PYSEC-2023-312 --ignore-vuln CVE-2026-28684 . + + precommit: + name: 'Pre-Commit' + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + cache: pip + - uses: pre-commit/action@v3.0.1 + env: + SKIP: prettier \ No newline at end of file diff --git a/.github/workflows/on_release.yml b/.github/workflows/on_release.yml index 1650a21b3..bae683f0a 100644 --- a/.github/workflows/on_release.yml +++ b/.github/workflows/on_release.yml @@ -15,9 +15,9 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 - name: Setup dependencies run: | npm install @semantic-release/git @semantic-release/exec --no-save diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 76c95057d..20104a9e7 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -41,14 +41,14 @@ jobs: uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.14' - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 24 check-latest: true - name: Cache pip @@ -76,7 +76,7 @@ jobs: run: | bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh pip install frappe-bench - bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench --frappe-branch develop mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index a74b84ef4..643e66738 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -38,13 +38,14 @@ jobs: uses: actions/checkout@v3 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.14' - - uses: actions/setup-node@v3 + - name: Setup Node + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 24 check-latest: true - name: Add to Hosts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0cc1cc5ac..967382669 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,12 @@ exclude: 'node_modules|.git' default_stages: [pre-commit] +default_install_hook_types: [pre-commit, commit-msg] fail_fast: false repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.13 + rev: v0.14.10 hooks: - id: ruff name: "Run ruff import sorter" @@ -26,19 +27,24 @@ repos: - vue additional_dependencies: - prettier@3.3.3 - - prettier-plugin-tailwindcss - args: - - --plugin=prettier-plugin-tailwindcss + - prettier-plugin-tailwindcss@0.6.11 exclude: | (?x)^( - frappe/public/dist/.*| + builder/public/dist/.*| .*node_modules.*| .*boilerplate.*| .*src.*.js| builder/public/js/identify.js| + builder/public/js/posthog.js| )$ + - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.23.0 + hooks: + - id: commitlint + stages: [commit-msg] + additional_dependencies: ['conventional-changelog-conventionalcommits'] + ci: autoupdate_schedule: weekly - skip: [] submodules: false diff --git a/builder/__init__.py b/builder/__init__.py index 6cea18d86..6c7936ab3 100644 --- a/builder/__init__.py +++ b/builder/__init__.py @@ -1 +1 @@ -__version__ = "1.18.0" +__version__ = "1.0.0-dev" diff --git a/builder/ai_page_generator.py b/builder/ai_page_generator.py new file mode 100644 index 000000000..7cb6af82a --- /dev/null +++ b/builder/ai_page_generator.py @@ -0,0 +1,671 @@ +import json +import re + +import frappe +import litellm +import yaml +from frappe import _ + +from builder.utils import has_page_write, to_compact_yaml + +litellm.drop_params = True + + +TASK_PARAMS = { + "simple": {"max_tokens": 1000, "temperature": 0.5}, + "complex": {"max_tokens": 22000, "temperature": 0.7}, +} + + +# System Prompts + +MODIFY_PROMPT = ( + "You modify web sections in Frappe Builder's block system.\n" + "Return ONLY valid and compact 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. Support interactive states like hover:backgroundColor, active:color.\n" + "c?: [el]\n" + "attrs?: dict\n" + "text?: str\n" + "m_style?: dict\n\n" + "Rules: Preserve ALL existing 'id' values. Only change what requested. Return COMPLETE structure. " + "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.\n" + "Formatting: use flow style for all style dicts e.g. style: {color: '#fff', 'hover:backgroundColor': '#eee'}. " + "All images must be external URLs with proper alt text if replacing." + "Omit any key whose value is empty, null, or {}.\n" + "Gradients: ALWAYS use 'backgroundImage' (NOT 'background') for gradients. " + "The gradient value MUST be quoted to avoid YAML parse errors. " + "Example: backgroundImage: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'. " + "Never leave gradient strings unquoted." +) + +REWRITE_TEXT_PROMPT = ( + "You are a professional copywriter. Rewrite the provided text content to be more engaging and professional.\n" + "Return ONLY the rewritten text. No markdown, no explanations, no quotes." +) + +REPLACE_IMAGE_PROMPT = ( + "You are an image finder. Suggest a highly relevant, high-quality publicly available image URL " + "(different from what is provided).\n" + "Return ONLY the image URL. No markdown, no explanations, no quotes." +) + +GENERATE_PROMPT = """You are an expert web designer specializing in creating modern, responsive web pages using the Frappe Builder block system. + +Critical: Return ONLY a valid and compact YAML object. No markdown, no explanations. + +# Structure: +Return a single root block that represents the page (el: div, id: root). This block contains all sections in its 'c' (children) property. + +# Schema for the Page Container Object: +- el: div +- id: root +- name: body +- style: CSS-in-JS camelCase object for page-wide styles (e.g. { backgroundColor: '#f8f9fa', fontFamily: 'Inter', display: 'flex', flexDirection: 'column', alignItems: 'center' }) +- c: array of content blocks (sections, header, footer, etc.) + +# Content Block Schema: +- el: semantic HTML tag (section, nav, header, footer, h1-h3, p, span, button, a, img) +- name: descriptive name +- style: CSS-in-JS camelCase object. Include interactive states (e.g., 'hover:backgroundColor', 'active:transform', 'hover:color') for buttons and links. +- m_style: mobile overrides +- t_style: tablet overrides +- attrs: HTML attrs (src, alt, href, target) +- text: text content +- c: nested blocks array +- classes: CSS class names + +# Rules: +- The top-level Page block must have 'display: flex', 'flexDirection: column', and 'alignItems: center' to layout sections properly. +- All top-level sections inside 'c' MUST have 'width: 100%'. +- Modern harmonious color palettes. Good spacing. Professional concise copy. +- Interactive: Use hover states for buttons/links to make the page feel alive. +- Google Fonts via fontFamily (use ONLY the font name and not the fallback). +- Semantic HTML with alt texts. +- Create maximum 5 high quality sections +- Use semantic tags and wrap text in them. Never place text directly in a div/section without a semantic tag. +- Avoid using emojis in text content. Focus on professional tone. +- Gradients: ALWAYS use 'backgroundImage' (NOT 'background') for gradients. The value MUST be a quoted YAML string to avoid parse errors. Example: backgroundImage: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'. Never use unquoted gradient values.""" + +MODIFY_PROMPT_MAP = { + "rewrite_text": REWRITE_TEXT_PROMPT, + "replace_image": REPLACE_IMAGE_PROMPT, +} + + +def get_system_prompt(is_modify: bool, task_type: str | None = None) -> str: + if is_modify: + return MODIFY_PROMPT_MAP.get(task_type or "", MODIFY_PROMPT) + return GENERATE_PROMPT + + +def classify_task(is_modify: bool, task_type: str | None = None) -> str: + if is_modify and task_type in {"rewrite_text", "replace_image"}: + return "simple" + return "complex" + + +def compress_block_to_yaml(block: dict, depth: int = 0, task_tier: str = "complex") -> dict: + if not isinstance(block, dict): + return block + + out = {} + if block.get("element"): + out["el"] = block["element"] + if block.get("blockId"): + out["id"] = block["blockId"] + if block.get("blockName"): + out["name"] = block["blockName"] + + base_styles = block.get("baseStyles") or {} + if base_styles: + out["style"] = base_styles + + attrs = block.get("attributes") or {} + if attrs: + out["attrs"] = attrs + + if block.get("classes"): + out["classes"] = block["classes"] + if block.get("innerHTML"): + out["text"] = block["innerHTML"] + + mob = block.get("mobileStyles") or {} + if mob: + out["m_style"] = mob + + tab = block.get("tabletStyles") or {} + if tab and (task_tier == "complex" or depth <= 1): + out["t_style"] = tab + + children = [ + compress_block_to_yaml(c, depth + 1, task_tier) + for c in block.get("children", []) + if isinstance(c, dict) + ] + if children: + out["c"] = children + + return out + + +def extract_block_id(block_context: str) -> str | None: + """Extract blockId from raw JSON context without full re-parse later.""" + try: + data = json.loads(block_context) + if isinstance(data, list): + data = data[0] if data else {} + return data.get("blockId") if isinstance(data, dict) else None + except Exception: + return None + + +def strip_block_context(block_context: str, task_tier: str, task_type: str | None = None) -> str: + """Convert block JSON to compact YAML to reduce input tokens.""" + try: + data = json.loads(block_context) + except (json.JSONDecodeError, TypeError): + return block_context + + if isinstance(data, list): + data = data[0] if data else {} + if not isinstance(data, dict): + return block_context + + if task_type == "rewrite_text": + return data.get("innerHTML") or data.get("innerText") or "" + if task_type == "replace_image": + attrs = data.get("attributes", {}) + return to_compact_yaml({"src": attrs.get("src", ""), "alt": attrs.get("alt", "")}) + return to_compact_yaml([compress_block_to_yaml(data, 0, task_tier)]) + + +def expand_yaml_to_block(node: dict) -> dict: + """Expand compact YAML node back to Frappe Builder block schema.""" + if not isinstance(node, dict): + return node + + block = { + "element": node.get("el", "div"), + "blockName": node.get("name", ""), + "baseStyles": node.get("style", {}), + "attributes": node.get("attrs", {}), + "children": [expand_yaml_to_block(c) for c in node.get("c", []) if isinstance(c, dict)], + } + for yaml_key, block_key in [ + ("id", "blockId"), + ("text", "innerHTML"), + ("m_style", "mobileStyles"), + ("t_style", "tabletStyles"), + ("classes", "classes"), + ]: + if yaml_key in node: + block[block_key] = node[yaml_key] + + return block + + +def validate_image_data(image_data: str) -> str: + """Validate that image_data is a safe base64-encoded image data URL.""" + if not image_data.startswith("data:image/"): + frappe.throw(_("Invalid image data: must be a base64-encoded image data URL")) + if ";base64," not in image_data: + frappe.throw(_("Invalid image data: must be a base64-encoded data URL")) + # ~5 MB image ≈ ~6.7 MB base64 string + if len(image_data) > 7 * 1024 * 1024: + frappe.throw(_("Image is too large. Please use an image smaller than 5 MB.")) + return image_data + + +def build_user_message( + prompt: str, + is_modify: bool, + block_context: str | None = None, + task_type: str | None = None, + image_url: str | None = None, +) -> str | list: + if is_modify and block_context: + if task_type == "rewrite_text": + text = f'Text content to rewrite: "{block_context}"\n\nInstruction: {prompt}' + elif task_type == "replace_image": + text = f"Current image attributes:\n{block_context}\n\nInstruction: {prompt}" + else: + text = f"Block:\n{block_context}\n\nChange: {prompt}" + else: + text = f"Create a page for: {prompt}" + + if image_url: + return [ + {"type": "text", "text": text}, + {"type": "image_url", "image_url": {"url": image_url}}, + ] + return text + + +def call_llm(model: str, messages: list, params: dict, *, stream: bool, api_key: str | None = None): + """Call litellm. Returns chunk iterator (stream=True) or string (stream=False).""" + if model.startswith("gemini-"): + model = f"gemini/{model}" + + if "claude-" in model: + for m in messages: + if m["role"] == "system" and isinstance(m.get("content"), str): + m["content"] = [{"type": "text", "text": m["content"]}] + resp = litellm.completion(model=model, messages=messages, stream=stream, api_key=api_key, **params) + return resp if stream else (resp.choices[0].message.content or "") + + +def strip_fences(text: str) -> str: + text = re.sub(r"^```(?:yaml|json)?\s*\n?", "", text.strip()) + return re.sub(r"\n?```\s*$", "", text).strip() + + +def parse_blocks(content: str) -> dict: + """Parse LLM YAML output into a single block object.""" + parsed = yaml.safe_load(strip_fences(content)) + if isinstance(parsed, dict): + block = parsed + elif isinstance(parsed, list): + block = parsed[0] if parsed else {} + else: + raise ValueError("Not a valid block object") + + if not block: + raise ValueError("No valid blocks in response") + + if isinstance(block, dict) and not block.get("id"): + block["id"] = "root" + + return expand_yaml_to_block(block) + + +def run_llm_job( + prompt: str, + model: str, + api_key: str, + event_prefix: str, + is_modify: bool, + user: str | None = None, + page_id: str | None = None, + block_context: str | None = None, + task_type: str | None = None, + image_url: str | None = None, +): + user = user or frappe.session.user + + def emit(suffix, **kwargs): + event = f"{event_prefix}_{suffix}" + if page_id: + event = f"{event}_{page_id}" + payload = { + "page_id": page_id, + "task_type": task_type, + **kwargs, + } + frappe.publish_realtime(event, payload, user=user) + + task_tier = classify_task(is_modify=is_modify, task_type=task_type) + params = TASK_PARAMS[task_tier] + + cache_key = f"ai_streaming_content:{page_id}:{user}" if page_id else None + if cache_key: + frappe.cache().set_value(cache_key, {"content": "", "task_type": task_type}, expires_in_sec=600) + + if task_tier == "simple": + model = get_simple_model(model) + + action = "Modifying" if is_modify else "Generating" + model_label = get_model_label(model) + emit( + "progress", + status="generating", + message=f"{action} with {model_label}", + task_tier=task_tier, + model_used=model, + total_length=0, + ) + + original_id = None + stripped_context = None + if is_modify and block_context: + original_id = extract_block_id(block_context) + stripped_context = strip_block_context(block_context, task_tier, task_type=task_type) + + # Image is only applicable for generate/modify-block tasks, not simple text/image tasks + effective_image_url = image_url if task_type not in {"rewrite_text", "replace_image"} else None + + messages = [ + { + "role": "system", + "content": get_system_prompt(is_modify, task_type), + "cache_control": {"type": "ephemeral"}, + }, + { + "role": "user", + "content": build_user_message( + prompt, + is_modify, + block_context=stripped_context, + task_type=task_type, + image_url=effective_image_url, + ), + }, + ] + + content = "" + try: + last_stage = None + for chunk in call_llm(model, messages, params, stream=True, api_key=api_key): + if delta := chunk.choices[0].delta.content: + if not content: + emit("progress", message="Building...") + last_stage = "Building..." + content += delta + if cache_key: + frappe.cache().set_value( + cache_key, {"content": content, "task_type": task_type}, expires_in_sec=600 + ) + + emit("stream", chunk=delta, block_id=original_id, total_length=len(content)) + + stage = get_progress_stage(content) + if stage and stage != last_stage: + last_stage = stage + emit("progress", message=stage, total_length=len(content)) + + except ValueError as e: + if cache_key: + frappe.cache().delete_value(cache_key) + frappe.log_error(f"Parse error: {e}\nContent: {content}", f"{event_prefix} parse") + emit("error", message="Failed to parse AI response. The model returned invalid YAML.") + return + + except Exception as e: + if cache_key: + frappe.cache().delete_value(cache_key) + frappe.log_error(f"LLM job error: {e}", event_prefix) + emit("error", message=str(e)) + return + + if cache_key: + frappe.cache().delete_value(cache_key) + + success_message = "Modified block successfully" if is_modify else "Page generated successfully" + emit( + "complete", + block_id=original_id, + model_used=model, + task_tier=task_tier, + message=success_message, + ) + + +def generate_page_blocks( + prompt: str, + model: str, + api_key: str, + user: str | None = None, + page_id: str | None = None, + image_url: str | None = None, +): + run_llm_job( + prompt, + model, + api_key, + "ai_generation", + is_modify=False, + user=user, + page_id=page_id, + image_url=image_url, + ) + + +def modify_section_blocks( + prompt: str, + block_context: str, + model: str, + api_key: str, + user: str | None = None, + page_id: str | None = None, + task_type: str | None = None, + image_url: str | None = None, +): + run_llm_job( + prompt, + model, + api_key, + "ai_modify", + is_modify=True, + user=user, + page_id=page_id, + block_context=block_context, + task_type=task_type, + image_url=image_url, + ) + + +def enqueue_ai_job(fn, model=None, **kwargs): + if not frappe.has_permission("Builder Page", ptype="write"): + frappe.throw(_("You do not have permission to modify pages")) + settings = frappe.get_single("Builder Settings") + + if not model: + model = "openrouter" + + model = get_default_model(model) + + api_key = settings.get_password("ai_api_key", raise_exception=False) + if not api_key: + frappe.throw(_("Please configure an OpenRouter API key in Settings → AI")) + + frappe.enqueue( + fn, + model=model, + api_key=api_key, + user=frappe.session.user, + now=True, + **kwargs, + ) + frappe.local.response.http_status_code = 202 + return {"status": "accepted"} + + +AVAILABLE_MODELS = [ + { + "provider": "openrouter", + "models": [ + { + "name": "openrouter/anthropic/claude-sonnet-4.6", + "label": "Claude Sonnet 4.6 (Balanced)", + "max_tokens": 200000, + "vision": True, + }, + { + "name": "openrouter/anthropic/claude-haiku-4-6", + "label": "Claude Haiku 4.6 (Fast)", + "max_tokens": 200000, + "vision": True, + }, + { + "name": "openrouter/google/gemini-3.1-pro", + "label": "Gemini 3.1 Pro (Flagship)", + "max_tokens": 1048576, + "vision": True, + }, + { + "name": "openrouter/google/gemini-3-flash-preview", + "label": "Gemini 3 Flash (Fast)", + "max_tokens": 1048576, + "vision": True, + }, + { + "name": "openrouter/openai/gpt-5.4-mini", + "label": "GPT-5.4 Mini", + "max_tokens": 1000000, + "vision": True, + }, + { + "name": "openrouter/moonshotai/kimi-k2.5", + "label": "Kimi K2.5 (Cheapest)", + "max_tokens": 2000000, + "vision": True, + }, + { + "name": "openrouter/z-ai/glm-5", + "label": "GLM-5 (Balanced)", + "max_tokens": 200000, + "vision": True, + }, + { + "name": "openrouter/moonshotai/kimi-k2", + "label": "Kimi K2 (Generous Free Tier)", + "max_tokens": 131072, + "vision": False, + }, + ], + }, +] + + +def get_model_label(model_name: str) -> str: + for provider in AVAILABLE_MODELS: + for m in provider["models"]: + if m["name"] == model_name: + return m["label"] + # Fallback: clean up the name + return model_name.removeprefix("openrouter/").replace("/", " ").replace("-", " ").title() + + +def get_progress_stage(content: str) -> str | None: + lookback = content[-400:] + major_elements = ["section", "nav", "header", "footer"] + + # Find the most recent major element type + last_pos = -1 + found_el = None + for el in major_elements: + pos = lookback.rfind(f"el: {el}") + if pos > last_pos: + last_pos = pos + found_el = el + + if found_el and last_pos != -1: + part = lookback[last_pos:] + name_match = re.search(r"name:\s*['\"]?([^'\"\n]+)['\"]?", part) + if name_match: + block_name = name_match.group(1).strip() + if block_name.lower() not in {"body", "root", "container"}: + return f"Building {block_name}" + return None + + +PROVIDER_DEFAULT_MODEL: dict[str, str] = { + "openrouter": "openrouter/anthropic/claude-sonnet-4.6", +} + + +PROVIDER_SIMPLE_MODEL: dict[str, str] = { + "openrouter": "openrouter/google/gemini-3-flash-preview", +} + + +def detect_provider(model: str) -> str | None: + if model.lower().startswith("openrouter/"): + return "openrouter" + return None + + +def get_simple_model(model: str) -> str: + provider = detect_provider(model) + if provider is None: + if model in PROVIDER_SIMPLE_MODEL: + return PROVIDER_SIMPLE_MODEL[model] + return model + return PROVIDER_SIMPLE_MODEL.get(provider, model) + + +def get_default_model(model_or_provider: str) -> str: + if model_or_provider in PROVIDER_DEFAULT_MODEL: + return PROVIDER_DEFAULT_MODEL[model_or_provider] + return model_or_provider + + +@frappe.whitelist() +def get_ai_models(): + return AVAILABLE_MODELS + + +@frappe.whitelist() +@has_page_write() +def generate_page_from_prompt( + prompt: str, + page_id: str | None = None, + model: str | None = None, + image_data: str | None = None, +): + image_url = validate_image_data(image_data) if image_data else None + return enqueue_ai_job( + generate_page_blocks, prompt=prompt, page_id=page_id, model=model, image_url=image_url + ) + + +@frappe.whitelist() +@has_page_write() +def modify_section_from_prompt( + prompt: str, + block_context: str, + page_id: str | None = None, + task_type: str | None = None, + model: str | None = None, + image_data: str | None = None, +): + try: + json.loads(block_context) + except json.JSONDecodeError: + frappe.throw(_("Invalid block context JSON")) + image_url = validate_image_data(image_data) if image_data else None + return enqueue_ai_job( + modify_section_blocks, + prompt=prompt, + block_context=block_context, + page_id=page_id, + task_type=task_type, + model=model, + image_url=image_url, + ) + + +@frappe.whitelist() +@has_page_write() +def get_ai_streaming_content(page_id: str): + user = frappe.session.user + cache_key = f"ai_streaming_content:{page_id}:{user}" + return frappe.cache().get_value(cache_key) or {"content": None} + + +@frappe.whitelist() +@has_page_write() +def test_api_key(): + settings = frappe.get_single("Builder Settings") + model = settings.get("ai_model") or "openrouter" + api_key = settings.get_password("ai_api_key", raise_exception=False) + if not api_key: + return {"success": False, "message": _("Please set an OpenRouter API key")} + + actual_model = get_default_model(model) + if actual_model.startswith("gemini-"): + actual_model = f"gemini/{actual_model}" + try: + litellm.completion( + model=actual_model, + messages=[{"role": "user", "content": "Say 'OK' if you can read this"}], + max_tokens=10, + api_key=api_key, + ) + return {"success": True, "message": _("API key is valid")} + except Exception as e: + return {"success": False, "message": str(e)} diff --git a/builder/api.py b/builder/api.py index 6cd6a806f..3abdbb929 100644 --- a/builder/api.py +++ b/builder/api.py @@ -1,8 +1,7 @@ -import json import os from io import BytesIO from types import FunctionType, MethodType, ModuleType -from typing import TYPE_CHECKING, Any +from typing import Any from urllib.parse import unquote import frappe @@ -11,7 +10,6 @@ from frappe.apps import get_apps as get_permitted_apps from frappe.core.doctype.file.file import get_local_image from frappe.core.doctype.file.utils import delete_file -from frappe.integrations.utils import make_post_request from frappe.model.document import Document from frappe.utils.caching import redis_cache from frappe.utils.safe_exec import NamespaceDict, get_safe_globals @@ -21,33 +19,7 @@ from builder import builder_analytics from builder.builder.doctype.builder_page.builder_page import BuilderPageRenderer - - -@frappe.whitelist() -def get_blocks(prompt): - API_KEY = frappe.conf.openai_api_key - if not API_KEY: - frappe.throw("OpenAI API Key not set in site config.") - - messages = [ - { - "role": "system", - "content": "You are a website developer. You respond only with HTML code WITHOUT any EXPLANATION. You use any publicly available images in the webpage. You can use any font from fonts.google.com. Do not use any external css file or font files. DO NOT ADD diff --git a/frontend/src/components/AppsMenu.vue b/frontend/src/components/AppsMenu.vue deleted file mode 100644 index da090cc08..000000000 --- a/frontend/src/components/AppsMenu.vue +++ /dev/null @@ -1,39 +0,0 @@ - - diff --git a/frontend/src/components/ArrayEditor.vue b/frontend/src/components/ArrayEditor.vue new file mode 100644 index 000000000..1a43eb9ff --- /dev/null +++ b/frontend/src/components/ArrayEditor.vue @@ -0,0 +1,98 @@ + + diff --git a/frontend/src/components/ArrayInput.vue b/frontend/src/components/ArrayInput.vue new file mode 100644 index 000000000..a90a6f523 --- /dev/null +++ b/frontend/src/components/ArrayInput.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/components/BackgroundHandler.vue b/frontend/src/components/BackgroundHandler.vue index 65537c338..9b756e4aa 100644 --- a/frontend/src/components/BackgroundHandler.vue +++ b/frontend/src/components/BackgroundHandler.vue @@ -1,108 +1,244 @@