import json import os import re import logging import mimetypes import base64 from datetime import datetime from typing import Optional from contextlib import asynccontextmanager from fastapi import HTTPException, Query from fastapi.responses import Response from backend.auth import get_auth_token from jsonschema import validate as schema_validate, ValidationError as SchemaValidationError from backend.config.Apps import SubApp from backend.apps.outputs.models import ( Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult, VibeCodeRequest, WorkspaceSeedRequest, ) from backend.apps.outputs.executor import execute_backend_code, get_code_warnings from backend.apps.outputs.view_builder_templates import ( VIEW_TEMPLATE_FILES, load_app_builder_skill, seed_webapp_template_workspace, ) from backend.apps.settings.settings import load_settings logger = logging.getLogger(__name__) MODEL_MAP = { "sonnet": "claude-sonnet-4-20250514", "opus": "claude-opus-4-20250514", "haiku": "claude-haiku-4-5-20251001", } def _resolve_model(short_name: str) -> str: return MODEL_MAP.get(short_name, short_name) def _get_anthropic_client(api_model: str | None = None): """Create an AsyncAnthropic client using the API key from app settings. When `api_model` is provided and carries a 9Router prefix (cc/, cx/, gc/), the client is pointed at 9Router so non-Anthropic aux calls don't 400 on api.anthropic.com. Without an api_model we fall back to the default connection-mode-driven client. """ from backend.apps.settings.credentials import ( get_anthropic_client, get_anthropic_client_for_model, ) settings = load_settings() if api_model: return get_anthropic_client_for_model(settings, api_model) return get_anthropic_client(settings) def _validate_against_schema(data: dict, schema: dict) -> str | None: """Validate *data* against *schema*. Return an error string or None.""" try: schema_validate(instance=data, schema=schema) return None except SchemaValidationError as exc: path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "(root)" return f"Schema validation failed at {path}: {exc.message}" from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null") -> str: """Build a " ) def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null") -> str: injection = _build_data_injection(input_json, result_json, backend_url_json) if "" in html: return html.replace("", f"{injection}\n", 1) if " str: """Return the JSON-encoded backend URL for the given workspace, or "null" if no runtime is active. Cheap inline lookup so serve_workspace_file doesn't have to think about it.""" try: from backend.apps.outputs.runtime import manager as runtime_manager rt = runtime_manager.get(workspace_id) if rt and rt.running and rt.port: return json.dumps(f"http://127.0.0.1:{rt.port}") except Exception: logger.exception("backend url lookup failed for %s", workspace_id) return "null" # URL schemes / prefixes that must NOT have ?token= appended. These are either # external (CDNs, mailto) or non-network references that the auth middleware # never sees. Anything else is treated as a same-origin relative URL pointing # at our /api/outputs/.../serve/ subtree, which DOES need the token. _ABSOLUTE_URL_PREFIXES = ( "http://", "https://", "//", "data:", "blob:", "mailto:", "tel:", "javascript:", "about:", "#", ) _HREF_SRC_ATTR_RE = re.compile( r"""(\s(?:href|src))\s*=\s*(["'])([^"']+)\2""", re.IGNORECASE, ) def _inject_token_into_relative_urls(html: str, token: str) -> str: """Append `?token=` to every relative href/src in the served HTML. Browsers strip the parent iframe URL's query string before resolving relative `` / ` - Input data is at window.OUTPUT_INPUT (object), backend result at window.OUTPUT_BACKEND_RESULT. 2. **input_schema**: A JSON Schema object defining the structured input. 3. **backend_code** (optional): Python code where input_data is a global dict and result is a global dict to assign to. 4. **name**: A short name for the view. 5. **description**: A one-sentence description. 6. **message**: A brief explanation of what you did/changed. Return ONLY valid JSON with these keys. No markdown fences, no extra text.\ """ @outputs.router.post("/vibe-code") async def vibe_code(body: VibeCodeRequest): """Use an LLM to generate or iterate on Output code from a natural language prompt.""" try: import anthropic except ImportError: return { "message": "anthropic SDK not installed. Install with: pip install anthropic", "frontend_code": body.current_frontend_code, "backend_code": body.current_backend_code, "input_schema": body.current_schema, } context_parts = [] if body.current_frontend_code: context_parts.append(f"Current frontend code:\n```html\n{body.current_frontend_code}\n```") if body.current_backend_code: context_parts.append(f"Current backend code:\n```python\n{body.current_backend_code}\n```") if body.current_schema: context_parts.append(f"Current input schema:\n```json\n{body.current_schema}\n```") if body.name: context_parts.append(f"Current name: {body.name}") if body.description: context_parts.append(f"Current description: {body.description}") user_message = body.prompt if context_parts: user_message = "\n\n".join(context_parts) + "\n\nUser request: " + body.prompt from backend.apps.agents.providers.registry import resolve_aux_model try: aux_model, _aux_base = await resolve_aux_model(load_settings(), preferred_tier="sonnet") except ValueError as e: return { "message": f"Error: {str(e)}", "frontend_code": body.current_frontend_code, "backend_code": body.current_backend_code, "input_schema": body.current_schema, } client = _get_anthropic_client(aux_model) try: resp = await client.messages.create( model=aux_model, max_tokens=8000, system=VIBE_CODE_SYSTEM_PROMPT, messages=[{"role": "user", "content": user_message}], ) from backend.apps.agents.agent_manager import _safe_resp_text raw = _safe_resp_text(resp).strip() if not raw: return { "message": "Aux model returned no content. Please try again.", "frontend_code": body.current_frontend_code, "backend_code": body.current_backend_code, "input_schema": body.current_schema, } if raw.startswith("```"): raw = raw.split("\n", 1)[1] if "\n" in raw else raw[3:] if raw.endswith("```"): raw = raw[:-3] result = json.loads(raw) pass return { "message": result.get("message", "View updated."), "frontend_code": result.get("frontend_code", body.current_frontend_code), "backend_code": result.get("backend_code", body.current_backend_code), "input_schema": result.get("input_schema", body.current_schema), "name": result.get("name", body.name), "description": result.get("description", body.description), } except json.JSONDecodeError: return { "message": "I generated code but couldn't parse the response. Please try again.", "frontend_code": body.current_frontend_code, "backend_code": body.current_backend_code, "input_schema": body.current_schema, } except Exception as e: logger.exception("Vibe code generation failed") return { "message": f"Error: {str(e)}", "frontend_code": body.current_frontend_code, "backend_code": body.current_backend_code, "input_schema": body.current_schema, } @outputs.router.post("/execute") async def execute_output(body: OutputExecute): output = _load(body.output_id) validation_err = _validate_against_schema(body.input_data, output.input_schema) if validation_err: return OutputExecuteResult( output_id=output.id, output_name=output.name, frontend_code=output.frontend_code, input_data=body.input_data, backend_result=None, error=validation_err, ).model_dump() backend_result = None stdout_text = None stderr_text = None error = None warnings_out: Optional[list[str]] = None code_preview: Optional[str] = None if output.backend_code: # HITL gate: collect warnings up front. If the caller hasn't opted # in via force=True AND the code touches anything outside the safe # allowlist, return the warnings + the code itself so the UI can # show a preview dialog. No subprocess is spawned on this path , # zero-cost when warnings exist, identical-to-before when they # don't. if not body.force: warnings_out = get_code_warnings(output.backend_code) if warnings_out: code_preview = output.backend_code if not warnings_out: try: # We've either already vetted (no warnings above) or the # user explicitly opted in with force=True. Pass # skip_validation=True so we don't pay for a redundant # AST walk inside execute_backend_code. exec_result = await execute_backend_code( output.backend_code, body.input_data, skip_validation=True ) backend_result = exec_result.result stdout_text = exec_result.stdout stderr_text = exec_result.stderr except Exception as e: error = str(e) return OutputExecuteResult( output_id=output.id, output_name=output.name, frontend_code=output.frontend_code, input_data=body.input_data, backend_result=backend_result, stdout=stdout_text, stderr=stderr_text, error=error, warnings=warnings_out if warnings_out else None, code_preview=code_preview, ).model_dump()