From 181b83fc5ecf368e8893697a15faf1e852a1aee6 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Mon, 7 Sep 2026 10:23:38 +0200 Subject: [PATCH] deploy ProspectOS workspace v5 final --- .env.example | 3 + README.md | 37 +++- api/server.py | 420 ++++++++++++++++++++++++++++++++++++++++++++- docker-compose.yml | 1 + tests/test_app.py | 90 +++++++++- web/app.js | 97 +++++++++++ web/index.html | 4 +- web/style.css | 14 ++ 8 files changed, 649 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index e56fbf7..eba1d0f 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,6 @@ AI_PROVIDER=custom AI_BASE_URL= AI_API_KEY= AI_MODEL= +# Maximum time to wait for AI-written mockup copy before using the reliable +# premium fallback renderer. Keep this below your reverse-proxy timeout. +AI_MOCKUP_TIMEOUT_SECONDS=45 diff --git a/README.md b/README.md index 29ac8d8..ae3adb5 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,14 @@ a production site and should be checked and edited before sending. The design prompt receives a small category-matched library of Unsplash stock images; generated pages may use only those supplied image URLs, and the fallback template uses the same curated set. +Each saved concept also has an **Edit template** action. It opens the shareable +site beside a chat-style editor where you can ask for copy, emphasis, palette, +imagery, or layout changes. The request is sent to the configured AI with the +prospect's public evidence and rendered through the same deterministic, +sanitised template renderer. Every successful edit creates a new version and +share token; earlier versions remain in the prospect drawer and are never +overwritten. If AI is unavailable, safe visual hints such as “make it darker” +still select one of the built-in palettes. The **Logs** view separates API calls, user actions, AI requests, searches, Browser Use/tool steps, and errors. AI request payloads and responses are truncated and credentials such as API keys, authorization headers, tokens, and @@ -111,6 +119,7 @@ error rather than remaining stuck forever. | `AI_BASE_URL` | empty | Optional OpenAI-compatible `/chat/completions` endpoint | | `AI_API_KEY` | empty | Optional provider key; keep server-side | | `AI_MODEL` | empty | Model name used for evidence-grounded drafts | +| `AI_MOCKUP_TIMEOUT_SECONDS` | `45` | Maximum AI wait for mockup copy before the deterministic premium renderer is used (20–300 seconds) | ### Nous Research Portal @@ -171,10 +180,11 @@ The API equivalents are `POST /api/prospects/{id}/website-candidates`, Open **Settings → Browser and discovery timing** to change Browser Use's enabled state, total investigation timeout, maximum agent steps, and per-call AI timeout. The same panel controls the Maps worker wait timeout/poll interval, -search depth, and website health/email scan timeout. Values are stored in the -local SQLite database, survive container restarts, and override `.env` defaults -for new runs. The safe UI ranges prevent a malformed value from creating an -unbounded worker. +search depth, website health/email scan timeout, and the maximum wait for +AI-written mockup copy. Values are stored in the local SQLite database, survive +container restarts, and override `.env` defaults for new runs. The safe UI +ranges prevent a malformed value from creating an unbounded worker; a slow +mockup provider automatically falls back to the complete local renderer. The app stores its catalogue in the `prospect_data` Docker volume. The worker stores its own job data in `gmaps_data`; keep both volumes if you want to retain @@ -228,9 +238,22 @@ with `GET/POST /api/promotions`, `PATCH /api/promotions/{id}`, and is sent as `promotion_id` and retained on that prospect's draft. Mockups are created with `POST /api/prospects/{id}/mockup`, listed with `GET /api/prospects/{id}/mockups`, and opened by their public -`/mockups/{token}` share URL. Set `PUBLIC_BASE_URL` when the reverse proxy does -not forward the public host; otherwise the link is built from the request's -forwarded host or `Host` header. +`/mockups/{token}` share URL. Choose `mode=onepage` for a single scrollable +landing page or `mode=multipage` for real Home, Services, About, and Contact +routes. Both modes use the same deterministic premium renderer: responsive +sticky navigation, layered hero imagery, editorial service cards, accessible +contact calls-to-action, a dark structured footer, hover states, and CSS-only +entrance/ambient animations with reduced-motion support. AI is asked only for +bounded copy and curated image choices; malformed, truncated, unavailable, or +slow AI responses fall back to the same complete renderer instead of failing +the request. Set `PUBLIC_BASE_URL` when the reverse proxy does not forward the +public host; otherwise the link is built from the request's forwarded host or +`Host` header. +Use `GET /api/mockups/{id}` to load editor metadata and +`POST /api/mockups/{id}/edit` with `{"instruction":"...","page":"all"}` to +create the next version. The edit endpoint requires reviewer access, limits +instructions to 3,000 characters, and accepts `all`, `home`, `services`, +`about`, or `contact` as the page scope. ## Tests diff --git a/api/server.py b/api/server.py index 2931e1b..3b212ec 100644 --- a/api/server.py +++ b/api/server.py @@ -55,6 +55,12 @@ AI_BASE_URL = os.environ.get("AI_BASE_URL", "").strip().rstrip("/") AI_API_KEY = os.environ.get("AI_API_KEY", "").strip() AI_MODEL = os.environ.get("AI_MODEL", "").strip() AI_PROVIDER = os.environ.get("AI_PROVIDER", "custom").strip().lower() +try: + # Keep synchronous preview requests below common reverse-proxy limits; + # the deterministic renderer is returned if the provider is slower. + AI_MOCKUP_TIMEOUT_SECONDS = max(20, min(300, int(os.environ.get("AI_MOCKUP_TIMEOUT_SECONDS", "45")))) +except (TypeError, ValueError): + AI_MOCKUP_TIMEOUT_SECONDS = 45 # Cloudflare Browser Integrity checks can reject Python's default # ``Python-urllib/`` fingerprint before an OpenAI-compatible request # reaches the provider. Identify this application consistently on provider @@ -616,6 +622,9 @@ def db() -> sqlite3.Connection: generation_source TEXT NOT NULL DEFAULT 'fallback', instructions TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'published', + parent_id INTEGER, + version INTEGER NOT NULL DEFAULT 1, + edit_instructions TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )""") @@ -734,7 +743,15 @@ def db() -> sqlite3.Connection: if name not in existing_fields: conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") existing_mockup_fields = {row[1] for row in conn.execute("PRAGMA table_info(mockups)")} - for name, ddl in {"mode": "TEXT NOT NULL DEFAULT 'onepage'", "pages_json": "TEXT NOT NULL DEFAULT '[]'"}.items(): + for name, ddl in { + "mode": "TEXT NOT NULL DEFAULT 'onepage'", + "pages_json": "TEXT NOT NULL DEFAULT '[]'", + # Template edits are immutable versions. Existing v1-v4 rows remain + # valid roots; new editor versions point back to their root row. + "parent_id": "INTEGER", + "version": "INTEGER NOT NULL DEFAULT 1", + "edit_instructions": "TEXT NOT NULL DEFAULT ''", + }.items(): if name not in existing_mockup_fields: conn.execute(f"ALTER TABLE mockups ADD COLUMN {name} {ddl}") for table in ("prospects", "runs", "suppressions", "schedules", "website_candidates", "logs", "promotions", "prospect_notes", "prospect_tasks", "score_history", "ai_assessment_history", "audit_events", "run_events", "mockups"): @@ -758,6 +775,7 @@ def db() -> sqlite3.Connection: conn.execute("CREATE INDEX IF NOT EXISTS idx_run_events_run ON run_events(organization_id, run_id, id DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_prospect ON mockups(organization_id, prospect_id, id DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_token ON mockups(token)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_mockups_parent ON mockups(organization_id, parent_id, version DESC, id DESC)") conn.commit() _schema_path = DB_PATH _schema_lock.release() @@ -1406,7 +1424,8 @@ def ai_runtime_settings() -> dict[str, str]: base = saved.get("AI_BASE_URL", AI_BASE_URL).strip().rstrip("/") if provider == "nous": base = NOUS_BASE_URL - return {"provider": provider, "base_url": base, "api_key": saved.get("AI_API_KEY", AI_API_KEY).strip(), "model": saved.get("AI_MODEL", AI_MODEL).strip()} + mockup_timeout = _setting_int(saved, "AI_MOCKUP_TIMEOUT_SECONDS", "AI_MOCKUP_TIMEOUT_SECONDS", AI_MOCKUP_TIMEOUT_SECONDS, 20, 300) + return {"provider": provider, "base_url": base, "api_key": saved.get("AI_API_KEY", AI_API_KEY).strip(), "model": saved.get("AI_MODEL", AI_MODEL).strip(), "mockup_timeout_seconds": str(mockup_timeout)} def nous_models() -> list[dict[str, Any]]: @@ -2181,6 +2200,17 @@ def _mockup_text(value: Any, limit: int = 6000) -> str: return re.sub(r"\s+", " ", str(value or "")).strip()[:limit] +def _mockup_external_url(value: Any) -> str: + """Return a safe public HTTP(S) link for a generated preview.""" + candidate = normalize_url(_mockup_text(value, 700)) + parsed = urlparse(candidate) + if not candidate or parsed.scheme not in {"http", "https"} or not parsed.hostname: + return "" + if parsed.username or parsed.password: + return "" + return candidate + + def sanitize_mockup_html(value: Any) -> str: """Remove active or unsafe markup before a generated preview is published.""" text = str(value or "").strip() @@ -2300,7 +2330,7 @@ def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | No headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {} payload = {"model": runtime["model"], "temperature": 0.5, "max_tokens": 5000, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]} try: - result = http_json("POST", runtime["base_url"] + "/chat/completions", payload, headers=headers, timeout=120, error_prefix="ai") + result = http_json("POST", runtime["base_url"] + "/chat/completions", payload, headers=headers, timeout=int(runtime.get("mockup_timeout_seconds") or AI_MOCKUP_TIMEOUT_SECONDS), error_prefix="ai") content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else "" cleaned = sanitize_mockup_html(content) if cleaned: @@ -2386,6 +2416,271 @@ def generate_mockup_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | N return fallback, "fallback", runtime["model"] +def premium_default_content(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None) -> dict[str, Any]: + """Safe neutral copy used when AI has no reliable fact to add.""" + snapshot = snapshot or {} + name = _mockup_text(prospect.get("name"), 160) or "Your business" + category = _mockup_text(prospect.get("category") or prospect.get("search_category"), 120) or "Local business" + title = _mockup_text(snapshot.get("website_title") or prospect.get("website_title"), 180) + return { + "tagline": f"A sharper digital home for {name}.", + "intro": f"A modern, mobile-first way for people to understand {name} and take the next step.", + "services": [ + {"name": "Clear information", "description": "Put the most useful information within reach on every screen."}, + {"name": "Confident first impression", "description": "A considered visual system helps the business feel easy to trust."}, + {"name": "Easy next step", "description": "Make it simple for interested visitors to call, email, or enquire."}, + ], + "about": title or f"A thoughtful online presence for a {category.lower()} business.", + "cta": "Let's make it easier for customers to choose you.", + "hero_image": "", + "gallery_image": "", + } + + +def _premium_content(prospect: dict[str, Any], snapshot: dict[str, Any] | None, supplied: dict[str, Any] | None) -> dict[str, Any]: + defaults = premium_default_content(prospect, snapshot) + value = supplied if isinstance(supplied, dict) else {} + result = dict(defaults) + for key, limit in (("tagline", 180), ("intro", 600), ("about", 700), ("cta", 220)): + text = _mockup_text(value.get(key), limit) + if text: + result[key] = text + services: list[dict[str, str]] = [] + raw_services = value.get("services") + if isinstance(raw_services, list): + for item in raw_services[:6]: + if isinstance(item, dict): + service_name = _mockup_text(item.get("name"), 100) + service_description = _mockup_text(item.get("description"), 280) + if service_name and service_description: + services.append({"name": service_name, "description": service_description}) + if services: + # Keep the visual system balanced even when a free model returns only + # one or two usable service cards. Neutral cards are safer than + # inventing a fourth business-specific claim. + for fallback_item in defaults["services"]: + if len(services) >= 3: + break + services.append(fallback_item) + result["services"] = services[:6] + allowed_images = stock_images_for_category(prospect.get("category") or prospect.get("search_category")) + result["hero_image"] = str(value.get("hero_image") or "") if str(value.get("hero_image") or "") in allowed_images else allowed_images[0] + result["gallery_image"] = str(value.get("gallery_image") or "") if str(value.get("gallery_image") or "") in allowed_images else allowed_images[1] + return result + + +def _premium_design(value: Any = None) -> dict[str, Any]: + """Return a deliberately tiny, validated visual vocabulary for edits. + + The AI never gets to emit arbitrary CSS. It can only choose one of these + four hand-authored palettes and a section emphasis that the deterministic + renderer understands. + """ + raw = value if isinstance(value, dict) else {} + variant = str(raw.get("variant") or raw.get("theme") or raw.get("design_variant") or "default").strip().lower() + if variant not in {"default", "midnight", "warm", "editorial"}: + variant = "default" + emphasis = str(raw.get("emphasis") or "balanced").strip().lower() + if emphasis not in {"balanced", "services", "about", "contact", "hero"}: + emphasis = "balanced" + show_gallery = raw.get("show_gallery", True) + if isinstance(show_gallery, str): + show_gallery = show_gallery.strip().lower() not in {"0", "false", "no", "off"} + return {"variant": variant, "emphasis": emphasis, "show_gallery": bool(show_gallery)} + + +def _premium_design_from_instructions(instructions: str, current: Any = None) -> dict[str, Any]: + """Apply safe local visual hints when a model is unavailable or terse.""" + design = _premium_design(current) + text = str(instructions or "").lower() + if re.search(r"\b(dark|darker|midnight|black|navy|night)\b", text): + design["variant"] = "midnight" + elif re.search(r"\b(warm|terracotta|beige|earthy|sand|copper)\b", text): + design["variant"] = "warm" + elif re.search(r"\b(editorial|magazine|print|luxury serif)\b", text): + design["variant"] = "editorial" + if re.search(r"\b(service|services|offerings|what we do)\b", text): + design["emphasis"] = "services" + elif re.search(r"\b(contact|call|email|enquir|cta)\b", text): + design["emphasis"] = "contact" + elif re.search(r"\b(about|story|heritage)\b", text): + design["emphasis"] = "about" + elif re.search(r"\b(hero|headline|opening)\b", text): + design["emphasis"] = "hero" + if re.search(r"\b(hide|remove|without)\b[^.]{0,40}\b(gallery|image|images)\b", text): + design["show_gallery"] = False + elif re.search(r"\b(show|add|include)\b[^.]{0,40}\b(gallery|image|images)\b", text): + design["show_gallery"] = True + return design + + +def premium_render_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, supplied: dict[str, Any] | None = None, mode: str = "multipage", design: Any = None) -> dict[str, str]: + """Render a polished, self-contained concept site from bounded facts/copy.""" + snapshot = snapshot or {} + content = _premium_content(prospect, snapshot, supplied) + design = _premium_design(design) + name_raw = _mockup_text(prospect.get("name"), 160) or "Your business" + category_raw = _mockup_text(prospect.get("category") or prospect.get("search_category"), 120) or "Local business" + name = html_escape(name_raw) + category = html_escape(category_raw) + address = html_escape(_mockup_text(prospect.get("address"), 240)) + phone = _mockup_text(prospect.get("phone"), 90) + email = _mockup_text(prospect.get("emails"), 180).split(",")[0].strip() + website = _mockup_external_url(prospect.get("website") or prospect.get("website_final_url") or snapshot.get("website_url")) + title = html_escape(_mockup_text(snapshot.get("website_title") or prospect.get("website_title"), 180) or name_raw) + phone_link = html_escape(re.sub(r"[^0-9+]+", "", phone)) + email_link = html_escape(email) + website_link = html_escape(website) + hero_image = html_escape(content["hero_image"]) + gallery_image = html_escape(content["gallery_image"]) + base = "__MOCKUP_BASE__" + is_multipage = mode == "multipage" + services = content["services"] + cards = "".join(f'
{index:02d}

{html_escape(item["name"])}

{html_escape(item["description"])}

↗
' for index, item in enumerate(services, 1)) + contact_links = "".join(item for item in [ + f'Call {html_escape(phone)}' if phone else "", + f'Email {html_escape(email)}' if email else "", + f'Current website ↗' if website else "", + ] ) + location_line = " · ".join(item for item in [address, category] if item) + css = ''' +:root{--ink:#101827;--ink-soft:#41506a;--muted:#78869c;--paper:#fbfcff;--card:#fff;--line:#e6ebf3;--accent:#635bff;--accent-2:#16a37a;--dark:#0c1423;--dark-soft:#aebbd0;--gold:#e7b86b;--max:1180px;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper);line-height:1.5}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;overflow-x:hidden}a{color:inherit}.preview{position:sticky;top:0;z-index:20;text-align:center;padding:9px 15px;background:linear-gradient(90deg,#0a1220,#1b2841 50%,#0a1220);color:#dfe8f7;font:800 10px/1.2 system-ui;letter-spacing:.15em;text-transform:uppercase}.site-header{position:sticky;top:31px;z-index:10;border-bottom:1px solid #ffffff19;background:#ffffffde;backdrop-filter:blur(18px)}.nav{display:flex;align-items:center;justify-content:space-between;gap:24px;max-width:var(--max);margin:auto;padding:18px 28px}.brand{display:flex;align-items:center;gap:10px;min-width:0;text-decoration:none;font-size:16px;font-weight:900;letter-spacing:-.035em}.brand-mark{width:13px;height:13px;display:inline-block;border-radius:4px 9px 4px 9px;background:linear-gradient(135deg,var(--accent),#a29dff);box-shadow:0 7px 18px #635bff40}.navlinks{display:flex;align-items:center;gap:25px;color:#65738a;font-size:11px;font-weight:800}.navlinks a{text-decoration:none}.navlinks a:hover{color:var(--accent)}.nav-cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:0 15px;border-radius:999px;background:var(--ink);color:#fff;text-decoration:none;font-size:11px;font-weight:850;box-shadow:0 10px 20px #1018271c}.container{max-width:var(--max);margin:auto;padding:0 28px}.eyebrow{color:var(--accent);font-size:10px;font-weight:950;letter-spacing:.18em;text-transform:uppercase}.hero{position:relative;isolation:isolate;overflow:hidden;background:linear-gradient(135deg,#f1f3ff 0,#fff 55%,#edf9f6 100%);border-bottom:1px solid var(--line)}.hero:before,.hero:after{content:"";position:absolute;z-index:-1;border-radius:50%;filter:blur(3px);opacity:.48;animation:drift 14s ease-in-out infinite}.hero:before{width:430px;height:430px;right:-130px;top:-170px;background:#b9b4ff66}.hero:after{width:290px;height:290px;left:-170px;bottom:-160px;background:#99e1ca66;animation-delay:-6s}.hero-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.82fr);align-items:center;gap:70px;padding-top:100px;padding-bottom:94px}.hero h1{max-width:720px;margin:15px 0 18px;font-size:clamp(42px,6vw,78px);line-height:.98;letter-spacing:-.075em}.hero-copy{max-width:610px;color:var(--ink-soft);font-size:18px;line-height:1.65}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:30px}.btn{display:inline-flex;align-items:center;justify-content:center;gap:12px;min-height:48px;padding:0 19px;border-radius:12px;background:var(--accent);color:#fff;text-decoration:none;font-size:12px;font-weight:900;box-shadow:0 14px 27px #635bff35;transition:transform .2s ease,box-shadow .2s ease}.btn:hover{transform:translateY(-3px);box-shadow:0 18px 34px #635bff4a}.btn.ghost{border:1px solid #ccd5e4;background:#ffffffbc;color:var(--ink);box-shadow:none}.hero-visual{position:relative}.image-shell{position:relative;padding:11px;border:1px solid #ffffffc9;border-radius:29px;background:#ffffff88;box-shadow:0 32px 75px #27365324;transform:rotate(1.5deg);animation:float 7s ease-in-out infinite}.image-shell img{display:block;width:100%;height:390px;object-fit:cover;border-radius:20px;filter:saturate(.88) contrast(1.02)}.image-label{position:absolute;left:25px;bottom:25px;right:25px;padding:15px 17px;border:1px solid #ffffffaa;border-radius:15px;background:#101827d9;color:#fff;backdrop-filter:blur(14px)}.image-label b{display:block;font-size:18px;letter-spacing:-.04em}.image-label span{display:block;margin-top:4px;color:#c4d0e1;font-size:11px}.floating-note{position:absolute;right:-30px;top:38px;padding:11px 13px;border:1px solid #ffffff;background:#fff;color:var(--ink);border-radius:13px;box-shadow:0 17px 35px #27365324;font-size:10px;font-weight:850;animation:float 6s ease-in-out infinite reverse}.floating-note i{display:inline-block;width:7px;height:7px;margin-right:6px;border-radius:50%;background:var(--accent-2)}.trust-rail{display:flex;flex-wrap:wrap;gap:10px;padding:18px 0 0;color:#75839a;font-size:10px;font-weight:750}.trust-rail span{padding:8px 11px;border:1px solid #dfe5ef;border-radius:999px;background:#ffffffaa}.section{padding:100px 0}.section-head{max-width:700px}.section h2{margin:11px 0 13px;font-size:clamp(30px,4vw,52px);line-height:1.04;letter-spacing:-.06em}.section-intro{max-width:650px;color:var(--muted);font-size:16px;line-height:1.7}.service-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:34px}.service-card{position:relative;min-height:225px;padding:25px;border:1px solid var(--line);border-radius:19px;background:var(--card);box-shadow:0 14px 38px #2635520a;transition:transform .25s ease,box-shadow .25s ease,border-color .25s ease}.service-card:hover{transform:translateY(-7px);border-color:#c8c4ff;box-shadow:0 22px 45px #26355218}.service-index{color:var(--accent);font:900 11px ui-monospace,monospace;letter-spacing:.12em}.service-card h3{margin:38px 0 8px;font-size:18px;letter-spacing:-.03em}.service-card p{margin:0;max-width:240px;color:var(--muted);font-size:12px;line-height:1.65}.card-arrow{position:absolute;right:23px;bottom:21px;color:#a6afbe;font-size:18px}.split{display:grid;grid-template-columns:.9fr 1.1fr;gap:65px;align-items:center}.split-image{overflow:hidden;border-radius:23px;box-shadow:0 25px 60px #2635521a}.split-image img{display:block;width:100%;height:390px;object-fit:cover;transition:transform .8s ease}.split-image:hover img{transform:scale(1.04)}.detail-list{display:grid;gap:0;margin-top:26px;border-top:1px solid var(--line)}.detail-row{display:flex;justify-content:space-between;gap:20px;padding:15px 0;border-bottom:1px solid var(--line);font-size:12px}.detail-row span{color:var(--muted)}.detail-row b{text-align:right}.dark-section{background:var(--dark);color:#fff}.dark-section .eyebrow{color:#aaa3ff}.dark-section .section-intro{color:var(--dark-soft)}.cta-box{position:relative;overflow:hidden;display:grid;grid-template-columns:1fr auto;gap:35px;align-items:center;padding:38px;border:1px solid #ffffff1d;border-radius:22px;background:linear-gradient(115deg,#182642,#101a2d);box-shadow:0 25px 65px #07101f45}.cta-box:after{content:"";position:absolute;width:210px;height:210px;right:-70px;top:-110px;border-radius:50%;background:#8f8bff35}.cta-box h2{position:relative;z-index:1;margin:7px 0;font-size:clamp(26px,3.5vw,43px)}.contact-links{position:relative;z-index:1;display:flex;flex-wrap:wrap;justify-content:flex-end;gap:9px}.contact-chip{display:inline-flex;align-items:center;min-height:42px;padding:0 13px;border:1px solid #ffffff32;border-radius:10px;color:#fff;text-decoration:none;font-size:11px;font-weight:800}.contact-chip:hover{border-color:#bcb9ff;background:#ffffff10}.site-footer{background:#080f1b;color:#9aaac0}.footer-grid{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:35px;padding:48px 28px 35px}.footer-brand{color:#fff}.footer-grid h3{margin:0 0 12px;color:#fff;font-size:11px}.footer-grid p,.footer-grid a{display:block;margin:5px 0;color:#9aaac0;text-decoration:none;font-size:11px}.footer-grid a:hover{color:#fff}.footer-bottom{display:flex;justify-content:space-between;gap:20px;padding:15px 28px;border-top:1px solid #ffffff16;color:#71829a;font-size:10px}.reveal{animation:reveal .8s both}.reveal:nth-child(2){animation-delay:.08s}.reveal:nth-child(3){animation-delay:.16s}.reveal:nth-child(4){animation-delay:.24s}@keyframes reveal{from{opacity:0;transform:translateY(18px)}to{opacity:1;transform:none}}@keyframes drift{0%,100%{transform:translate3d(0,0,0) scale(1)}50%{transform:translate3d(25px,-18px,0) scale(1.08)}}@keyframes float{0%,100%{transform:translateY(0) rotate(1.5deg)}50%{transform:translateY(-9px) rotate(1.5deg)}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation:none!important;transition:none!important}}@media(max-width:820px){.site-header{top:30px}.nav{padding:15px 20px}.navlinks{gap:12px}.nav-cta{display:none}.container{padding:0 20px}.hero-grid{grid-template-columns:1fr;gap:35px;padding-top:68px;padding-bottom:62px}.hero h1{font-size:clamp(42px,13vw,63px)}.hero-copy{font-size:16px}.hero-visual{max-width:650px}.image-shell img{height:300px}.floating-note{right:10px}.section{padding:70px 0}.service-grid{grid-template-columns:1fr 1fr}.split{grid-template-columns:1fr;gap:32px}.split-image img{height:300px}.cta-box{grid-template-columns:1fr}.contact-links{justify-content:flex-start}.footer-grid{grid-template-columns:1fr 1fr;padding:38px 20px 28px}.footer-bottom{display:block;padding:14px 20px;line-height:1.8}}@media(max-width:520px){.navlinks{display:none}.service-grid{grid-template-columns:1fr}.hero-grid{padding-top:53px}.image-shell img{height:260px}.section h2{font-size:35px}.footer-grid{grid-template-columns:1fr}.footer-bottom{font-size:9px}} + ''' + css += '.navlinks a{position:relative;transition:color .2s ease}.navlinks a.active{color:var(--ink)}.navlinks a.active:after{content:"";position:absolute;left:0;right:0;bottom:-9px;height:2px;border-radius:2px;background:var(--accent)}.btn:focus-visible,.nav a:focus-visible,.contact-chip:focus-visible{outline:3px solid #635bff55;outline-offset:3px}.service-card,.split-image,.cta-box{isolation:isolate}@media(max-width:520px){.navlinks{display:flex;gap:10px;max-width:60vw;overflow:auto;white-space:nowrap}.navlinks a{font-size:10px}.nav{gap:10px;padding:14px 17px}.brand{font-size:14px}.navlinks a.active:after{bottom:-5px}}' + # The editor can request a visual direction, but only these fixed + # palettes are permitted. This keeps previews predictable and safe. + palette_overrides = { + "midnight": ':root{--ink:#f4f7ff;--ink-soft:#bfcae0;--muted:#94a6c2;--paper:#091321;--card:#111f33;--line:#263b58;--accent:#8d86ff;--accent-2:#28c69b;--dark:#050b14;--dark-soft:#b2c0d7}.site-header{background:#0b1728e8;border-color:#ffffff14}.hero{background:linear-gradient(135deg,#0b1728 0,#101e35 58%,#102a2b 100%)}.floating-note{background:#132238;color:#eef4ff}.service-card{box-shadow:0 18px 42px #00000032}.btn.ghost{background:#112039;color:#eef4ff;border-color:#304765}', + "warm": ':root{--accent:#bd7047;--accent-2:#2b8b78;--paper:#fffaf5;--card:#fff;--line:#eee0d5;--ink:#2b211e;--ink-soft:#69534b;--muted:#907b72}.hero{background:linear-gradient(135deg,#f8eadf 0,#fffaf5 58%,#edf6f1 100%)}.brand-mark{background:linear-gradient(135deg,#bd7047,#e3b17e)}.btn{box-shadow:0 14px 27px #bd704738}', + "editorial": ':root{--accent:#a95643;--accent-2:#386f69;--paper:#faf8f3;--card:#fff;--line:#e5e0d6;--ink:#211c1b;--ink-soft:#5e5550;--muted:#867d76;font-family:Georgia,"Times New Roman",serif}.nav,.btn,.eyebrow,.service-index,.preview,.footer-grid h3,.footer-bottom{font-family:Inter,ui-sans-serif,system-ui,sans-serif}.hero{background:linear-gradient(135deg,#f1ece4 0,#faf8f3 58%,#e9f2ee 100%)}.service-card{border-radius:8px}.cta-box{border-radius:8px}' + } + css += palette_overrides.get(design["variant"], "") + if not design["show_gallery"]: + css += '.split.no-gallery{grid-template-columns:1fr;max-width:820px}' + + def href(slug: str) -> str: + if not is_multipage: + return base if slug == "home" else "#" + slug + return base if slug == "home" else base + "/" + slug + + def nav_html(active: str) -> str: + link_items: list[str] = [] + for slug, label in (("home", "Home"), ("services", "Services"), ("about", "About"), ("contact", "Contact")): + active_class = "active" if slug == active else "" + link_items.append(f'{label}') + links = "".join(link_items) + home_href = href("home") + contact_href = href("contact") + return f'' + + def footer_html() -> str: + contact_items = [ + f'{html_escape(phone)}' if phone else "", + f'{html_escape(email)}' if email else "", + f'Current website ↗' if website else "", + ] + contact = "".join(item for item in contact_items if item) + contact_fallback = f"

{location_line or name}

" + home_href = href("home") + services_href = href("services") + about_href = href("about") + contact_href = href("contact") + return f'
' + + def shell(slug: str, page_title: str, body: str) -> str: + return f'{name} · {html_escape(page_title)}
Website concept preview · prepared for review
{nav_html(slug)}
{body}
{footer_html()}' + + trust_address = f'{address}' if address else "" + trust_contact = 'Public contact available' if phone or email else "" + contact_href = href("contact") + services_href = href("services") + hero = f'
{category}

{html_escape(content["tagline"])}

{html_escape(content["intro"])}

{category}{trust_address}{trust_contact}
{category} website concept image
{title}Clear structure · considered detail · built for every screen
Mobile-first concept
' + services_section = f'
A BETTER EXPERIENCE

Make every detail feel intentional.

{html_escape(content["intro"])}

{cards}
' + location_row = f'
Location{address}
' if address else "" + split_class = "container split" + (" no-gallery" if not design["show_gallery"] else "") + visual_section = f'
{f"
\"{category}
" if design["show_gallery"] else ""}
THE DIFFERENCE

Designed to guide attention.

{html_escape(content["about"])}

FocusClarity on mobile and desktop
Direction{category}
{location_row}
' + cta_fallback = f'View contact details ↗' + cta_section = f'
READY WHEN YOU ARE

{html_escape(content["cta"])}

A focused online presence can give the right people a clear next step.

' + contact_fallback_chip = f'{location_line or name}' + pages = { + "home": shell("home", "Home", hero + services_section + visual_section + cta_section), + "services": shell("services", "Services", hero.replace(f'

{html_escape(content["tagline"])}

', f'

A considered way to present {name}.

').replace("Start a conversation", "Discuss your goals") + services_section + cta_section), + "about": shell("about", "About", hero.replace(f'

{html_escape(content["tagline"])}

', f'

A clearer story for {name}.

').replace("Start a conversation", "Get to know the business") + visual_section + services_section + cta_section), + "contact": shell("contact", "Contact", hero.replace(f'

{html_escape(content["tagline"])}

', f'

Let’s make the next step easy.

').replace("Start a conversation", "Get in touch") + f'
CONTACT

Let’s make the next step easy.

Use the public contact details below to start a conversation with {name}.

' + cta_section), + } + return pages if mode == "multipage" else {"home": pages["home"]} + + +def generate_premium_mockup(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "", mode: str = "onepage") -> tuple[dict[str, str], str, str]: + """Ask AI for compact content JSON, then deterministically render premium HTML.""" + snapshot = snapshot or {} + mode = "multipage" if str(mode or "onepage").strip().lower() in {"multiple", "multipage", "multi-page", "multi_page"} else "onepage" + requested_design = _premium_design_from_instructions(instructions) + fallback = premium_render_pages(prospect, snapshot, mode=mode, design=requested_design) + runtime = ai_runtime_settings() + if not runtime["base_url"] or not runtime["model"] or (runtime["provider"] == "nous" and not runtime["api_key"]): + return fallback, "fallback", "" + source = { + "business_name": _mockup_text(prospect.get("name"), 160), + "category": _mockup_text(prospect.get("category") or prospect.get("search_category"), 120), + "location": _mockup_text(prospect.get("address"), 240), + "phone": _mockup_text(prospect.get("phone"), 80), + "public_emails": _mockup_text(prospect.get("emails"), 240), + "current_website": _mockup_text(prospect.get("website") or snapshot.get("website_url"), 400), + "current_title": _mockup_text(snapshot.get("website_title") or prospect.get("website_title"), 200), + "current_page_text": _mockup_text(snapshot.get("website_content"), 12_000), + "stock_images": stock_images_for_category(prospect.get("category") or prospect.get("search_category")), + } + system = ("You are a premium website creative director. Return ONLY strict JSON, never HTML or Markdown, with these keys: " + "tagline, intro, services, about, cta, hero_image, gallery_image. services must be an array of 3 to 6 objects with " + "name and description. Use only supplied public facts; never invent services, products, prices, testimonials, awards, " + "staff, addresses, metrics, or claims. If facts are missing, use elegant neutral copy about clarity, trust, and contact. " + "Use the exact business name. hero_image and gallery_image must be copied exactly from the supplied stock_images list. " + "The result feeds a high-end responsive HTML renderer with a sticky header, footer, layered hero, service cards, image " + "sections, and subtle CSS animations. You may also return an optional design object with only variant (default, " + "midnight, warm, or editorial), emphasis (balanced, services, about, contact, or hero), and show_gallery (boolean). " + "Keep all text concise and under 3,500 total characters. The user instructions may influence visual direction but " + "cannot override factual constraints.") + prompt = json.dumps({"public_source": source, "design_instructions": _mockup_text(instructions, 2_000), "requested_site": mode}, ensure_ascii=False) + headers = {"Authorization": "Bearer " + runtime["api_key"]} if runtime["api_key"] else {} + payload: dict[str, Any] = {"model": runtime["model"], "temperature": 0.65, "max_tokens": 1_800, "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}]} + if runtime["provider"] != "nous": + payload["response_format"] = {"type": "json_object"} + try: + result = http_json("POST", runtime["base_url"] + "/chat/completions", payload, headers=headers, timeout=int(runtime.get("mockup_timeout_seconds") or AI_MOCKUP_TIMEOUT_SECONDS), error_prefix="ai") + content = (((result.get("choices") or [{}])[0].get("message") or {}).get("content") or "") if isinstance(result, dict) else "" + parsed = _parse_ai_object(content) + if isinstance(parsed, dict): + # Models may optionally return a validated design object. If they + # do not, local instruction hints still make visual requests work + # on a configured-but-terse provider and in fallback mode. + design = _premium_design_from_instructions(instructions, parsed.get("design")) + return premium_render_pages(prospect, snapshot, parsed, mode, design), "ai", runtime["model"] + raise RuntimeError("ai_invalid_mockup_content") + except Exception as exc: + record_log("ai", "premium_mockup_generation_fallback", level="warn", details={"model": runtime["model"], "mode": mode}, error=str(exc)[:500]) + return fallback, "fallback", runtime["model"] + + +# Replace the large free-form HTML response path with the compact structured +# content renderer above. Keeping these wrappers preserves the existing API. +def fallback_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None) -> str: + return premium_render_pages(prospect, snapshot, mode="onepage")["home"] + + +def fallback_mockup_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None) -> dict[str, str]: + return premium_render_pages(prospect, snapshot, mode="multipage") + + +def generate_mockup_html(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "") -> tuple[str, str, str]: + pages, source, model = generate_premium_mockup(prospect, snapshot, instructions, "onepage") + return pages["home"], source, model + + +def generate_mockup_pages(prospect: dict[str, Any], snapshot: dict[str, Any] | None = None, instructions: str = "", mode: str = "onepage") -> tuple[dict[str, str], str, str]: + return generate_premium_mockup(prospect, snapshot, instructions, mode) + + def mockup_public_url(handler: BaseHTTPRequestHandler, token: str, slug: str | None = None) -> str: base = PUBLIC_BASE_URL if not base: @@ -2544,7 +2839,7 @@ class Handler(BaseHTTPRequestHandler): "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"])), "models": models}, "browser_use": browser_runtime, "email_collection": {"always_on": True, "public_only": True}, - "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": scan_timeout}, + "limits": {"max_results": MAX_RESULTS, "website_scan_timeout_seconds": scan_timeout, "ai_mockup_timeout_seconds": int(ai_runtime.get("mockup_timeout_seconds") or AI_MOCKUP_TIMEOUT_SECONDS)}, "company_profile": company_profile_settings(saved), }) if parsed.path == "/api/prospects": @@ -2605,6 +2900,24 @@ class Handler(BaseHTTPRequestHandler): if not role_allows(getattr(self, "_auth_user", None), "admin"): return self.send_json(403, {"error": "permission_denied"}) return self.send_json(200, {"items": list_backups()}) + match = re.fullmatch(r"/api/mockups/(\d+)", parsed.path) + if match: + mockup_id = int(match.group(1)) + organization_id = getattr(self, "_organization_id", 1) + row = conn.execute("""SELECT id,organization_id,prospect_id,token,title,source_url,mode, + ai_model,generation_source,instructions,status,parent_id,version,edit_instructions,created_at,updated_at + FROM mockups WHERE id=? AND organization_id=? AND status='published'""", (mockup_id, organization_id)).fetchone() + if not row: + return self.send_json(404, {"error": "not_found"}) + item = dict(row) + item["share_url"] = mockup_public_url(self, item["token"]) + page_rows = conn.execute("SELECT pages_json FROM mockups WHERE id=? AND organization_id=?", (mockup_id, organization_id)).fetchone() + try: + pages = json.loads((page_rows[0] if page_rows else "[]") or "[]") + item["pages"] = [{"slug": slug, "url": mockup_public_url(self, item["token"], slug)} for slug in pages if slug in {"home", "services", "about", "contact"}] if isinstance(pages, dict) else [] + except (TypeError, ValueError, json.JSONDecodeError): + item["pages"] = [] + return self.send_json(200, item) match = re.fullmatch(r"/api/prospects/(\d+)/website-candidates", parsed.path) if match: prospect_id = int(match.group(1)) @@ -2619,7 +2932,7 @@ class Handler(BaseHTTPRequestHandler): if not conn.execute("SELECT 1 FROM prospects WHERE id=? AND organization_id=?", (prospect_id, organization_id)).fetchone(): return self.send_json(404, {"error": "not_found"}) rows = conn.execute("""SELECT id,prospect_id,token,title,source_url,mode,pages_json,ai_model,generation_source, - instructions,status,created_at,updated_at FROM mockups + instructions,status,parent_id,version,edit_instructions,created_at,updated_at FROM mockups WHERE prospect_id=? AND organization_id=? ORDER BY id DESC""", (prospect_id, organization_id)).fetchall() items = [] for row in rows: @@ -2747,7 +3060,15 @@ class Handler(BaseHTTPRequestHandler): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("X-Robots-Tag", "noindex, nofollow") - self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data: https://images.unsplash.com; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + # The editor embeds the same-origin preview in a sandboxed iframe. A + # self-only frame policy permits that workflow while still preventing + # third-party framing; the preview remains script/form/tracking-free. + # Only the editor asks for ``?embed=1``. Public share links remain + # unframeable by default; the explicit same-origin embed mode is still + # script/form/tracking-free and is used by the sandboxed editor iframe. + embed_requested = parse_qs(urlparse(self.path).query).get("embed", [""])[0] == "1" + frame_policy = "'self'" if embed_requested else "'none'" + self.send_header("Content-Security-Policy", f"default-src 'none'; style-src 'unsafe-inline'; img-src data: https://images.unsplash.com; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors {frame_policy}") self.send_header("Cache-Control", "public, max-age=300") self.send_header("Content-Length", str(len(body))) self.end_headers() @@ -2948,6 +3269,8 @@ class Handler(BaseHTTPRequestHandler): save_setting("GMAPS_MAX_DEPTH", max(1, min(10, int(maps.get("max_depth"))))) if "website_scan_timeout_seconds" in limits: save_setting("WEBSITE_SCAN_TIMEOUT", max(2, min(60, int(limits.get("website_scan_timeout_seconds"))))) + if "ai_mockup_timeout_seconds" in limits: + save_setting("AI_MOCKUP_TIMEOUT_SECONDS", max(20, min(300, int(limits.get("ai_mockup_timeout_seconds"))))) for field, key in COMPANY_PROFILE_KEYS.items(): # Website content/title/timestamp are written by the @@ -2967,7 +3290,7 @@ class Handler(BaseHTTPRequestHandler): "ai": {"provider": ai_runtime["provider"], "base_url": ai_runtime["base_url"], "model": ai_runtime["model"], "configured": bool(ai_runtime["base_url"] and ai_runtime["model"] and (ai_runtime["provider"] != "nous" or ai_runtime["api_key"]))}, "browser_use": browser_use_settings(), "maps": gmaps_runtime_settings(), - "limits": {"website_scan_timeout_seconds": website_scan_timeout_seconds()}, + "limits": {"website_scan_timeout_seconds": website_scan_timeout_seconds(), "ai_mockup_timeout_seconds": int(ai_runtime.get("mockup_timeout_seconds") or AI_MOCKUP_TIMEOUT_SECONDS)}, "company_profile": company_profile_settings(_saved_settings()), }) except Exception as exc: @@ -3202,6 +3525,89 @@ class Handler(BaseHTTPRequestHandler): conn.close() except Exception: pass + mockup_edit_match = re.fullmatch(r"/api/mockups/(\d+)/edit", path) + if mockup_edit_match: + if not role_allows(getattr(self, "_auth_user", None), "reviewer"): + return self.send_json(403, {"error": "permission_denied"}) + conn: sqlite3.Connection | None = None + mockup_id = int(mockup_edit_match.group(1)) + organization_id = getattr(self, "_organization_id", 1) + try: + conn = db() + row = conn.execute("SELECT * FROM mockups WHERE id=? AND organization_id=? AND status='published'", (mockup_id, organization_id)).fetchone() + if not row: + return self.send_json(404, {"error": "not_found"}) + prospect_row = conn.execute("SELECT * FROM prospects WHERE id=? AND organization_id=?", (int(row["prospect_id"]), organization_id)).fetchone() + if not prospect_row: + return self.send_json(404, {"error": "prospect_not_found"}) + payload = self.read_json() + instruction = _mockup_text(payload.get("instruction") or payload.get("instructions"), 3_000) + if len(instruction) < 3: + return self.send_json(400, {"error": "edit_instruction_required"}) + page = str(payload.get("page") or "all").strip().lower().replace(" ", "-") + if page not in {"all", "home", "services", "about", "contact"}: + page = "all" + mode = "multipage" if str(row["mode"] or "onepage").lower() == "multipage" else "onepage" + prior = _mockup_text(row["instructions"], 1_800) + scope_hint = "all pages" if page == "all" else f"the {page} page (keep the same direction on the other pages)" + combined_instructions = _mockup_text( + (prior + "\n\n" if prior else "") + f"Editor request for {scope_hint}: {instruction}", + 4_000, + ) + prospect = dict(prospect_row) + source_url = _mockup_external_url(row["source_url"] or prospect.get("website") or prospect.get("website_final_url")) + snapshot: dict[str, Any] = {} + if source_url: + try: + snapshot = fetch_company_page(source_url) + except Exception as exc: + record_log("search", "mockup_edit_source_fetch_failed", level="warn", prospect_id=int(row["prospect_id"]), + details={"url": _safe_log_url(source_url), "mockup_id": mockup_id}, error=str(exc)[:500]) + record_log("ai", "mockup_edit_started", prospect_id=int(row["prospect_id"]), + details={"mockup_id": mockup_id, "page": page, "mode": mode, "instruction": instruction[:500]}) + pages, generation_source, model = generate_mockup_pages(prospect, snapshot, combined_instructions, mode) + token = secrets.token_urlsafe(24) + base_url = mockup_public_url(self, token) + pages = {slug: value.replace("__MOCKUP_BASE__", base_url) for slug, value in pages.items()} + html = pages.get("home") or fallback_mockup_html(prospect, snapshot) + root_id = int(row["parent_id"] or row["id"]) + version_row = conn.execute("SELECT COALESCE(MAX(version),1)+1 FROM mockups WHERE organization_id=? AND (id=? OR parent_id=?)", (organization_id, root_id, root_id)).fetchone() + version = int(version_row[0] or 2) + base_title = re.sub(r"\s+·\s+v\d+$", "", str(row["title"] or "Website concept")).strip() or "Website concept" + title = f"{base_title} · v{version}" + stamp = now() + cur = conn.execute("""INSERT INTO mockups + (organization_id,prospect_id,created_by,token,title,source_url,html,mode,pages_json,ai_model,generation_source,instructions,status,parent_id,version,edit_instructions,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (organization_id, int(row["prospect_id"]), getattr(self, "_user_id", None), token, + title, str(snapshot.get("website_url") or source_url)[:500], html, mode, json.dumps(pages, ensure_ascii=False), model, generation_source, + combined_instructions, "published", root_id, version, instruction, stamp, stamp)) + new_id = int(cur.lastrowid) + conn.execute("UPDATE prospects SET ai_mockup_id=?,updated_at=? WHERE id=? AND organization_id=?", (new_id, stamp, int(row["prospect_id"]), organization_id)) + conn.commit() + share_url = mockup_public_url(self, token) + record_log("ai", "mockup_edited", prospect_id=int(row["prospect_id"]), + details={"mockup_id": new_id, "parent_id": root_id, "version": version, "source": generation_source, + "model": model, "page": page, "share_url": share_url}) + record_audit("mockup_edited", organization_id=organization_id, user_id=getattr(self, "_user_id", None), + entity_type="mockup", entity_id=new_id, + details={"prospect_id": int(row["prospect_id"]), "parent_id": root_id, "version": version, "page": page, "source": generation_source}) + return self.send_json(201, {"id": new_id, "prospect_id": int(row["prospect_id"]), "parent_id": root_id, + "version": version, "token": token, "mode": mode, "title": title, + "source_url": str(snapshot.get("website_url") or source_url), + "generation_source": generation_source, "ai_model": model, "created_at": stamp, + "share_url": share_url, "change_summary": f"Applied your request to {scope_hint}.", + "pages": [{"slug": slug, "url": mockup_public_url(self, token, slug)} for slug in pages]}) + except RuntimeError as exc: + record_log("ai", "mockup_edit_failed", level="error", details={"mockup_id": mockup_id}, error=str(exc)[:500]) + return self.send_json(503, {"error": str(exc)}) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + return self.send_json(400, {"error": str(exc)}) + except sqlite3.Error as exc: + record_log("ai", "mockup_edit_persist_failed", level="error", details={"mockup_id": mockup_id}, error=str(exc)[:500]) + return self.send_json(500, {"error": "mockup_edit_persist_failed"}) + finally: + if conn is not None: + conn.close() mockup_match = re.fullmatch(r"/api/prospects/(\d+)/mockup", urlparse(self.path).path) if mockup_match: if not role_allows(getattr(self, "_auth_user", None), "reviewer"): diff --git a/docker-compose.yml b/docker-compose.yml index d41223a..fb64b77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,7 @@ services: AI_BASE_URL: ${AI_BASE_URL:-} AI_API_KEY: ${AI_API_KEY:-} AI_MODEL: ${AI_MODEL:-} + AI_MOCKUP_TIMEOUT_SECONDS: ${AI_MOCKUP_TIMEOUT_SECONDS:-45} volumes: - prospect_data:/data depends_on: diff --git a/tests/test_app.py b/tests/test_app.py index 313bdf2..7f34652 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -201,6 +201,92 @@ class BrowserOnlyAppTests(unittest.TestCase): self.assertIn("Services", body) self.assertIn("images.unsplash.com", body) self.assertIn("https://images.unsplash.com", response.getheader("Content-Security-Policy")) + self.assertIn("frame-ancestors 'none'", response.getheader("Content-Security-Policy")) + + def test_mockup_editor_creates_a_new_version_and_keeps_original(self): + conn = server.db() + conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Brightspark Electrical", "maps:bright-editor", "electrician", server.now())) + conn.commit(); conn.close() + status, original = self.request("POST", "/api/prospects/1/mockup", {"mode": "multipage"}) + self.assertEqual(status, 201) + self.assertEqual(original.get("version"), None) # v1 rows remain backwards-compatible + conn = server.db() + for key, value in (("AI_PROVIDER", "nous"), ("AI_MODEL", "meituan/longcat-2.0:free"), ("AI_API_KEY", "test-key")): + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now())) + conn.commit(); conn.close() + ai_response = {"choices": [{"message": {"content": json.dumps({ + "tagline": "Powerful work. Presented with confidence.", + "intro": "A sharper digital home for Brightspark Electrical.", + "services": [{"name": "Electrical services", "description": "A clear way to understand the work available."}], + "about": "A refined concept based on the public business listing.", + "cta": "Let’s make the next step easy.", + "hero_image": server.STOCK_IMAGE_LIBRARY["electric"][0], + "gallery_image": server.STOCK_IMAGE_LIBRARY["electric"][1], + })}}]} + with patch("api.server.http_json", return_value=ai_response) as request: + status, edited = self.request("POST", f"/api/mockups/{original['id']}/edit", {"instruction": "Make the whole site darker and put more emphasis on services.", "page": "all"}) + self.assertEqual(status, 201) + self.assertEqual(edited["version"], 2) + self.assertEqual(edited["parent_id"], original["id"]) + self.assertEqual(edited["mode"], "multipage") + self.assertIn("Applied your request", edited["change_summary"]) + self.assertIn("max_tokens", request.call_args.args[2]) + self.assertEqual(request.call_args.args[2]["max_tokens"], 1800) + self.conn.request("GET", "/mockups/" + edited["token"] + "/services?embed=1") + response = self.conn.getresponse(); body = response.read().decode("utf-8") + self.assertEqual(response.status, 200) + self.assertIn("frame-ancestors 'self'", response.getheader("Content-Security-Policy")) + self.assertIn("--paper:#091321", body) + self.assertIn("Electrical services", body) + self.conn.request("GET", "/mockups/" + edited["token"]) + response = self.conn.getresponse(); edited_home = response.read().decode("utf-8") + self.assertEqual(response.status, 200) + self.assertIn("Powerful work", edited_home) + self.conn.request("GET", "/mockups/" + original["token"] + "/services") + response = self.conn.getresponse(); original_body = response.read().decode("utf-8") + self.assertEqual(response.status, 200) + self.assertIn("Brightspark Electrical", original_body) + status, metadata = self.request("GET", f"/api/mockups/{edited['id']}") + self.assertEqual(status, 200) + self.assertEqual(metadata["version"], 2) + status, listed = self.request("GET", "/api/prospects/1/mockups") + self.assertEqual(status, 200) + self.assertEqual([item["version"] for item in listed["items"]], [2, 1]) + + def test_multipage_ai_uses_compact_content_and_renders_all_pages(self): + conn = server.db() + conn.execute("INSERT INTO prospects(name,domain,category,discovered_at) VALUES(?,?,?,?)", ("Brightspark Electrical", "maps:bright-ai", "electrician", server.now())) + for key, value in (("AI_PROVIDER", "nous"), ("AI_MODEL", "meituan/longcat-2.0:free"), ("AI_API_KEY", "test-key")): + conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?)", (key, value, server.now())) + conn.commit(); conn.close() + response = {"choices": [{"message": {"content": json.dumps({ + "tagline": "Powerful work. Presented with clarity.", + "intro": "A considered digital home for Brightspark Electrical.", + "services": [{"name": "Electrical services", "description": "A clear way to understand the work available."}], + "about": "A refined concept grounded in the public business listing.", + "cta": "Let’s make the next step easy.", + "hero_image": server.STOCK_IMAGE_LIBRARY["electric"][0], + "gallery_image": server.STOCK_IMAGE_LIBRARY["electric"][1], + })}}]} + with patch("api.server.http_json", return_value=response) as request: + status, created = self.request("POST", "/api/prospects/1/mockup", {"mode": "multipage"}) + self.assertEqual(status, 201) + self.assertEqual(created["generation_source"], "ai") + self.assertEqual({item["slug"] for item in created["pages"]}, {"home", "services", "about", "contact"}) + payload = request.call_args.args[2] + self.assertEqual(payload["max_tokens"], 1800) + self.assertNotIn("pages", json.dumps(payload["messages"])) + stored = server.db().execute("SELECT pages_json FROM mockups WHERE id=?", (created["id"],)).fetchone() + pages = json.loads(stored[0]) + self.assertIn("Powerful work", pages["home"]) + self.assertTrue(all("Brightspark Electrical" in html and "@keyframes" in html and "item.disabled=true);if(status){status.textContent=`Generating ${mode==='multipage'?'multi-page':'one-page'} preview… The current public website may take a moment to read.`;status.className='notice'}try{const data=await api(`/api/prospects/${id}/mockup`,{method:'POST',body:JSON.stringify({instructions:'',mode})});if(status){status.innerHTML=`Preview ready (${esc(data.mode==='multipage'?'multiple pages':'one page')}). Open share link ↗`;status.className='notice ok'}const list=$('mockupsList');if(list){const latest=await api(`/api/prospects/${id}/mockups`);list.innerHTML=renderMockupItems(latest.items||[])}return data}catch(error){if(status){status.textContent=error.message;status.className='notice error'}throw error}finally{if(button)button.disabled=false;if(chooser)chooser.querySelectorAll('button').forEach(item=>item.disabled=false)}} async function loadMessageMockupOption(id){const context=$('messageContext');if(!context)return;context.querySelector('.mockup-option')?.remove();try{const data=await api(`/api/prospects/${id}/mockups`);const latest=(data.items||[])[0];if(!latest)return;const option=document.createElement('label');option.className='mockup-option';option.innerHTML=`Include latest concept previewShare the editable preview link once in the email. Open it ↗`;context.append(option)}catch(error){logAction('mockup_option_load_failed',{prospect_id:id,error:error.message},'warn')}} document.addEventListener('click',async e=>{const generate=e.target.closest('[data-generate-mockup]');if(generate){e.preventDefault();try{await generateMockupPreview(generate.dataset.generateMockup,generate,generate.dataset.mockupMode||'onepage')}catch{}return}const copy=e.target.closest('[data-copy-mockup]');if(copy){e.preventDefault();try{await navigator.clipboard.writeText(copy.dataset.copyMockup);copy.textContent='Copied';setTimeout(()=>{copy.textContent='Copy link'},1400)}catch{prompt('Copy preview link',copy.dataset.copyMockup)}}}); + +/* Keep the mockup generation timeout visible with the other runtime controls. */ +const _renderSettingsWithMockupTimeout=renderSettings; +renderSettings=function(){ + _renderSettingsWithMockupTimeout(); + const websiteTimeout=$('websiteTimeout'); + const group=websiteTimeout?.closest('.setting-group'); + if(!group||$('aiMockupTimeout'))return; + const label=document.createElement('label'); + label.innerHTML=`AI mockup copy timeout (seconds)After this wait the complete premium fallback renders automatically.`; + group.append(label); +}; +/* Capture this form before the legacy handler so the new value is saved in the same request. */ +document.addEventListener('submit',async e=>{ + if(e.target?.id!=='runtimeSettingsForm')return; + e.preventDefault();e.stopImmediatePropagation(); + const message=$('runtimeSettingsMessage'); + if(message){message.textContent='Saving…';message.className='notice'} + try{ + const result=await api('/api/settings',{method:'POST',body:JSON.stringify({ + browser_use:{enabled:$('browserEnabled').checked,max_steps:Number($('browserSteps').value),timeout_seconds:Number($('browserTimeout').value),llm_timeout_seconds:Number($('browserLlmTimeout').value)}, + maps:{request_timeout_seconds:Number($('mapsTimeout').value),poll_seconds:Number($('mapsPoll').value),max_depth:Number($('mapsDepth').value)}, + limits:{website_scan_timeout_seconds:Number($('websiteTimeout').value),ai_mockup_timeout_seconds:Number($('aiMockupTimeout')?.value||45)} + })}); + settings.browser_use=result.browser_use||settings.browser_use;settings.maps={...settings.maps,...(result.maps||{})};settings.limits={...settings.limits,...(result.limits||{})}; + if(message){message.textContent='Runtime settings saved.';message.className='notice ok'} + }catch(error){if(message){message.textContent=error.message;message.className='notice error'}} +},true); + +/* AI template editor ----------------------------------------------------- */ +/* + * Keep the preview in an iframe and the conversation in the parent app. + * The public preview itself is static and CSP/sandboxed, so an AI request + * can never inject a chat script, form handler, or tracking pixel into the + * share link. + */ +renderMockupItems=function(items=[]){ + if(!items.length)return '

No preview yet. Generate one from the public facts already collected for this prospect.

'; + return `
${items.map(item=>`
${esc(item.title||'Website concept preview')}${esc(item.mode==='multipage'?'Multi-page site':'One-page site')} · ${esc(item.generation_source==='ai'?'AI-generated':'Instant fallback')} · version ${Number(item.version||1)} · ${date(item.created_at)}${item.pages?.length>1?`${item.pages.length} pages · ${item.pages.map(page=>`${esc(page.slug)}`).join(' · ')}`:''}${item.source_url?`Based on ${esc(item.source_url)}`:''}${Number(item.version||1)>1?`Edited from an earlier version${item.edit_instructions?`: ${esc(item.edit_instructions)}`:''}`:''}
Open preview ↗
`).join('')}
`; +}; + +function editorPageOptions(mode){ + return mode==='multipage'?'':''; +} +function editorPreviewUrl(url,refresh=''){ + const value=String(url||'');if(!value)return ''; + const join=value.includes('?')?'&':'?';return `${value}${join}embed=1${refresh?`&refresh=${encodeURIComponent(refresh)}`:''}`; +} + +function editorAddMessage(messages,kind,text){ + if(!messages)return; + const bubble=document.createElement('div');bubble.className=`mockup-chat-message ${kind||'assistant'}`;bubble.textContent=String(text||'');messages.append(bubble);messages.scrollTop=messages.scrollHeight; +} + +async function openMockupEditor(button){ + const id=button?.dataset?.editMockup; + if(!id)return; + document.getElementById('mockupEditorDialog')?.remove(); + let state={id:Number(id),prospect_id:Number(button.dataset.editProspect||0),share_url:button.dataset.editShare||'',mode:button.dataset.editMode||'onepage',version:Number(button.dataset.editVersion||1),title:'Website concept preview'}; + const dialog=document.createElement('dialog');dialog.id='mockupEditorDialog';dialog.className='mockup-editor-dialog'; + dialog.innerHTML=`
TEMPLATE EDITOR

${esc(state.title)}

Ask the AI for a focused change, then review the new version before sharing it.

Version ${state.version}
Live concept previewOpen full preview ↗
`; + document.body.append(dialog); + const close=()=>{try{dialog.close()}catch{}dialog.remove()}; + dialog.querySelector('[data-editor-close]')?.addEventListener('click',close); + dialog.addEventListener('cancel',e=>{e.preventDefault();close()}); + dialog.addEventListener('click',e=>{if(e.target===dialog)close()}); + try{dialog.showModal()}catch{dialog.setAttribute('open','')} + const messages=$('mockupEditorMessages'),frame=$('mockupEditorFrame'),openLink=$('mockupEditorOpen'),versionLabel=$('mockupEditorVersion'),form=$('mockupEditorForm'),input=$('mockupEditorInput'),status=$('mockupEditorStatus'),apply=$('mockupEditorApply'); + editorAddMessage(messages,'assistant','I can refine the layout, copy, palette, emphasis, and imagery while keeping the concept grounded in the public prospect evidence. What would you like changed?'); + try{ + const remote=await api(`/api/mockups/${state.id}`);state={...state,...remote}; + const editorTitle=dialog.querySelector('.mockup-editor-head h2');if(editorTitle)editorTitle.textContent=state.title||'Website concept preview'; + if(frame&&state.share_url)frame.src=editorPreviewUrl(state.share_url);if(openLink)openLink.href=state.share_url||'#';if(versionLabel)versionLabel.textContent=`Version ${Number(state.version||1)}`; + }catch(error){editorAddMessage(messages,'system',`Could not load editor metadata: ${error.message}. The preview can still be edited.`)} + form?.addEventListener('submit',async event=>{ + event.preventDefault(); + const instruction=String(input?.value||'').trim();if(instruction.length<3){if(status){status.textContent='Tell the AI what you would like changed.';status.className='notice error'}return} + const page=$('mockupEditorPage')?.value||'all'; + if(apply)apply.disabled=true;if(input)input.disabled=true;if(status){status.textContent='Applying your request and rendering a new version…';status.className='notice'} + editorAddMessage(messages,'user',instruction); + try{ + const result=await api(`/api/mockups/${state.id}/edit`,{method:'POST',body:JSON.stringify({instruction,page})}); + state={...state,...result};const editorTitle=dialog.querySelector('.mockup-editor-head h2');if(editorTitle)editorTitle.textContent=state.title||'Website concept preview';editorAddMessage(messages,'assistant',`${result.change_summary||'Your change was applied.'} Version ${Number(result.version||1)} is ready to review.`); + if(frame&&result.share_url)frame.src=editorPreviewUrl(result.share_url,Date.now());if(openLink)openLink.href=result.share_url||'#';if(versionLabel)versionLabel.textContent=`Version ${Number(result.version||1)}`;if(input)input.value='';if(status){status.textContent='New version ready. Review the preview, then ask for another change or copy the share link.';status.className='notice ok'} + if(state.prospect_id){try{const latest=await api(`/api/prospects/${state.prospect_id}/mockups`);const list=$('mockupsList');if(list)list.innerHTML=renderMockupItems(latest.items||[])}catch{}} + }catch(error){editorAddMessage(messages,'system',`I could not apply that change: ${error.message}`);if(status){status.textContent=error.message;status.className='notice error'}}finally{if(apply)apply.disabled=false;if(input)input.disabled=false;input?.focus()} + }); + input?.focus(); +} + +/* Capture before row-level click handlers so opening the editor does not + also open/close the prospect drawer underneath it. */ +document.addEventListener('click',e=>{ + const edit=e.target.closest('[data-edit-mockup]'); + if(!edit)return; + e.preventDefault();e.stopPropagation();openMockupEditor(edit); +},true); diff --git a/web/index.html b/web/index.html index f836a9a..2d589bc 100644 --- a/web/index.html +++ b/web/index.html @@ -1,3 +1,3 @@ -ProspectOS -
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Tailor a message

Use the evidence and your offer to create an editable draft.

+ProspectOS +
Workspace/Dashboard
—
ProspectOS · public data only · manual review before contact

Tailor a message

Use the evidence and your offer to create an editable draft.

diff --git a/web/style.css b/web/style.css index 454931c..fa04086 100644 --- a/web/style.css +++ b/web/style.css @@ -158,3 +158,17 @@ input::placeholder { color: #aeb6c5; } input:focus, select:focus { border-color: @media(max-width:680px){.mockup-card{display:block}.mockup-card-actions{margin-top:9px;justify-content:flex-start}.mockup-card-actions a,.mockup-card-actions button{flex:1}.mockup-panel .primary{width:100%}} .mockup-mode-chooser{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin:12px 0}.mockup-mode-chooser button{display:grid;justify-items:start;gap:3px;text-align:left;padding:11px 12px}.mockup-mode-chooser button b{font-size:11px}.mockup-mode-chooser button small{font-size:10px;font-weight:500;opacity:.8}.mockup-mode-chooser .secondary{color:#48556c} @media(max-width:560px){.mockup-mode-chooser{grid-template-columns:1fr}.mockup-mode-chooser button{width:100%}} + +/* AI template editor */ +.mockup-editor-dialog{width:min(1240px,calc(100vw - 28px));height:min(860px,calc(100vh - 28px));max-width:none;max-height:none;padding:0;border:0;border-radius:18px;background:#f8f9fd;color:#27344b;box-shadow:0 30px 100px #18233d45;overflow:hidden} +.mockup-editor-dialog::backdrop{background:#111a2dcc;backdrop-filter:blur(5px)} +.mockup-editor-shell{display:grid;grid-template-rows:auto minmax(0,1fr);height:100%;min-height:0} +.mockup-editor-head{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:20px 24px;border-bottom:1px solid #e0e5ef;background:#fff} +.mockup-editor-head h2{margin:5px 0 4px;font-size:20px;letter-spacing:-.035em}.mockup-editor-head p{margin:0;color:#7b879a;font-size:11px}.mockup-editor-head-actions{display:flex;align-items:center;gap:10px}.mockup-editor-head-actions .icon-btn{flex:0 0 auto} +.mockup-editor-layout{display:grid;grid-template-columns:minmax(0,1fr) 370px;min-height:0} +.mockup-editor-preview{display:grid;grid-template-rows:auto minmax(0,1fr);min-width:0;background:#e9edf5}.mockup-editor-preview-bar{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:11px 17px;border-bottom:1px solid #dce2ed;background:#f7f8fc;color:#5a687d;font-size:11px}.mockup-editor-preview-bar a{color:#5b50d3;text-decoration:none;font-weight:800;white-space:nowrap}.mockup-editor-preview iframe{display:block;width:100%;height:100%;min-height:360px;border:0;background:#fff} +.mockup-editor-chat{display:grid;grid-template-rows:auto minmax(0,1fr) auto auto;min-height:0;border-left:1px solid #dfe4ed;background:#fff}.mockup-chat-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:19px 19px 13px;border-bottom:1px solid #edf0f5}.mockup-chat-head h3{margin:4px 0 0;font-size:16px;letter-spacing:-.025em}.mockup-chat-badge{padding:5px 8px;border-radius:999px;background:#eef8f5;color:#17876b;font-size:9px;font-weight:900;white-space:nowrap} +.mockup-chat-messages{display:grid;align-content:start;gap:10px;overflow:auto;padding:16px 16px 10px;background:linear-gradient(180deg,#fbfcff,#fff)}.mockup-chat-message{max-width:92%;padding:10px 11px;border-radius:11px;font-size:11px;line-height:1.5;white-space:pre-wrap}.mockup-chat-message.assistant{justify-self:start;border:1px solid #e1e5ef;background:#f5f6fb;color:#59677e}.mockup-chat-message.user{justify-self:end;background:#635bff;color:#fff;box-shadow:0 8px 18px #635bff2b}.mockup-chat-message.system{justify-self:start;border:1px solid #f1c7cd;background:#fff5f6;color:#a33e4d} +.mockup-chat-form{display:grid;gap:8px;padding:14px 16px 12px;border-top:1px solid #edf0f5}.mockup-chat-form label{display:grid;gap:5px;color:#627087;font-size:10px;font-weight:850}.mockup-chat-form select{margin:0;height:34px;font-size:11px}.mockup-chat-form textarea{margin:0;min-height:106px;resize:vertical;font-size:12px;line-height:1.5}.mockup-chat-form .notice{margin:0;font-size:10px;line-height:1.4}.mockup-chat-form button{width:100%;min-height:42px}.mockup-editor-note{display:grid;gap:4px;padding:12px 16px 16px;background:#fafbff;color:#8b96a8;font-size:10px;line-height:1.45}.mockup-editor-note b{color:#5c6a80;font-size:10px}.mockup-editor-note span{display:block} +@media(max-width:900px){.mockup-editor-dialog{width:min(760px,calc(100vw - 20px));height:min(900px,calc(100vh - 20px))}.mockup-editor-layout{grid-template-columns:1fr;grid-template-rows:minmax(310px,1fr) auto}.mockup-editor-preview iframe{min-height:300px}.mockup-editor-chat{grid-template-rows:auto minmax(95px,1fr) auto auto;border-left:0;border-top:1px solid #dfe4ed;max-height:430px}.mockup-chat-messages{min-height:95px;max-height:160px}} +@media(max-width:560px){.mockup-editor-dialog{width:100vw;height:100vh;border-radius:0}.mockup-editor-head{padding:15px 16px}.mockup-editor-head h2{font-size:17px}.mockup-editor-head p{max-width:230px}.mockup-editor-head-actions .pill{display:none}.mockup-editor-layout{grid-template-rows:minmax(260px,1fr) auto}.mockup-editor-preview-bar{padding:9px 12px}.mockup-editor-chat{max-height:430px}.mockup-chat-form{padding:11px 12px}.mockup-editor-note{padding:10px 12px 13px}}