webcake-landing-mcp 1.0.50 → 1.0.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -4
- package/dist/changelog.json +14 -14
- package/dist/domains/landing/guide.js +1 -1
- package/dist/domains/landing/instructions.js +9 -4
- package/dist/domains/landing/validate.js +43 -0
- package/dist/domains/landing/vocab.js +41 -0
- package/dist/persistence/draft-cache.js +24 -5
- package/dist/persistence/pexels-client.js +13 -2
- package/dist/persistence/webcake-client.js +37 -10
- package/dist/smoke.js +203 -0
- package/dist/tools/persistence.js +430 -69
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -419,8 +419,9 @@ update_page({ page_id, source, dry_run: false }) # overwrite (dry_run=tr
|
|
|
419
419
|
# Build a LARGE page incrementally (avoids the giant single create_page payload
|
|
420
420
|
# that can drop the connection): small skeleton first, then one section at a time.
|
|
421
421
|
create_page({ source: smallSkeleton, dry_run: false }) # → page_id
|
|
422
|
-
add_section({ page_id, sections: heroSection
|
|
423
|
-
add_section({ page_id,
|
|
422
|
+
add_section({ page_id, sections: heroSection }) # dry_run=true → validates + returns draft_id
|
|
423
|
+
add_section({ page_id, draft_id, dry_run: false }) # re-run with draft_id — no re-send of sections
|
|
424
|
+
add_section({ page_id, sections: [formSection, footerSection], dry_run: false }) # or skip dry-run entirely
|
|
424
425
|
|
|
425
426
|
# Go LIVE (the preview link works without this — publish to attach a domain / set live status)
|
|
426
427
|
publish_page({ page_id, custom_domain: "shop.example.com", custom_path: "sale", dry_run: false })
|
|
@@ -467,11 +468,13 @@ Both `create_page` and `update_page` **default to `dry_run=true`** (validate and
|
|
|
467
468
|
| Tool | Description |
|
|
468
469
|
|------|-------------|
|
|
469
470
|
| `list_organizations` | List the account's organizations (id, name, is_default). Default = the `is_default` org. |
|
|
470
|
-
| `create_page` | Persist a generated source as a new page (source-only). **Defaults to `dry_run=true`.** |
|
|
471
|
+
| `create_page` | Persist a generated source as a new page (source-only). Validates, caches the source as `draft_id`, then creates. On validation failure, timeout, or network error the draft is kept — retry via `create_page({ draft_id, dry_run:false })` or fix via `patch_page({ draft_id, patches })`. **Defaults to `dry_run=true`.** |
|
|
471
472
|
| `list_pages` | List the account's pages (id, name, organization_id, updated_at) to pick one to edit. |
|
|
472
473
|
| `find_pages` | Search the account's pages by name, domain, and/or page id (AND-combined) to locate one to edit; returns id, name, org, custom/default domain, updated_at. |
|
|
473
474
|
| `get_page` | Fetch an existing page's decoded source tree, COMPACTED to the sparse authoring shape (factory-default boilerplate stripped — far fewer tokens; `compact:false` for the raw tree). Edit and send back as-is. |
|
|
474
|
-
| `update_page` | Overwrite an existing page's source with an edited tree. **Defaults to `dry_run=true`.** |
|
|
475
|
+
| `update_page` | Overwrite an existing page's source with an edited tree. Validates, caches the source as `draft_id`, then saves. On timeout or failure the draft is kept — retry via `update_page({ draft_id, dry_run:false })` or `patch_page({ draft_id, dry_run:false })` (no patches). **Defaults to `dry_run=true`.** |
|
|
476
|
+
| `add_section` | Append section(s) to an existing page without re-sending the whole source (incremental-build path). Always caches the batch as `draft_id`; re-run with `{ page_id, draft_id, dry_run:false }` — no need to re-send sections. Validation failure, timeout, or network error also keeps the draft — fix via `patch_page({ draft_id, patches })` or retry `patch_page({ draft_id, dry_run:false })` with no patches. **Defaults to `dry_run=true`.** |
|
|
477
|
+
| `patch_page` | Edit a page by element id without re-sending the whole source. Targets a live page (`page_id`) OR a cached draft (`draft_id`). Draft kinds: `create_page` (creates page once valid), `add_section` (appends once valid), `update_page`/live-patch (retries updatePageSource). **Empty/omitted patches + `draft_id` = commit-as-is (the universal timeout-retry path).** Live-page path pre-caches the patched source before the network call and returns `draft_id` for recovery. **Defaults to `dry_run=true`.** |
|
|
475
478
|
| `publish_page` | Publish a page (live status, optional custom domain/path). The preview link works WITHOUT publishing — publish only to go live. **Defaults to `dry_run=true`.** |
|
|
476
479
|
|
|
477
480
|
---
|
package/dist/changelog.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "1.0.52",
|
|
4
|
+
"d": "10/06/2026",
|
|
5
|
+
"type": "Added",
|
|
6
|
+
"en": "validate_page now errors when an element type that the renderer cannot animate (any type other than group, image-block, text-block, rectangle,…",
|
|
7
|
+
"vi": "validate_page nay báo lỗi khi một element có loại không được renderer hỗ trợ animation (chỉ group, image-block, text-block, rectangle, button,…"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"v": "1.0.51",
|
|
11
|
+
"d": "10/06/2026",
|
|
12
|
+
"type": "Added",
|
|
13
|
+
"en": "create_page, update_page, and add_section dry-run responses now include a draft_id, and all three tools now accept draft_id as an input parameter:…",
|
|
14
|
+
"vi": "Các response dry-run của create_page, update_page và add_section nay đều trả về draft_id, đồng thời cả ba công cụ đều nhận draft_id làm tham số đầu…"
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"v": "1.0.50",
|
|
4
18
|
"d": "10/06/2026",
|
|
@@ -26,19 +40,5 @@
|
|
|
26
40
|
"type": "Added",
|
|
27
41
|
"en": "New patch_page tool edits an existing page by element id without re-sending the whole source: the agent sends per-element ops (update, replace,…",
|
|
28
42
|
"vi": "Công cụ patch_page mới cho phép chỉnh sửa trang hiện có theo element id mà không cần gửi lại toàn bộ source: agent gửi các op theo element (update,…"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
"v": "1.0.46",
|
|
32
|
-
"d": "09/06/2026",
|
|
33
|
-
"type": "Changed",
|
|
34
|
-
"en": "validate_page now emits an advisory warning when no section, button, or text on the page carries a non-neutral color (white, black, or grey),…",
|
|
35
|
-
"vi": "validate_page nay phát cảnh báo tư vấn khi không có section, button hay text nào trên trang mang màu thực sự (không phải trắng, đen hoặc xám), giúp…"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"v": "1.0.45",
|
|
39
|
-
"d": "09/06/2026",
|
|
40
|
-
"type": "Changed",
|
|
41
|
-
"en": "get_generation_guide workflow condensed to four steps: element-type reads and image fetches are now batched into single calls…",
|
|
42
|
-
"vi": "Workflow trong get_generation_guide được rút gọn xuống còn bốn bước: việc đọc loại phần tử và tìm ảnh nay được gộp thành các lần gọi batch duy nhất…"
|
|
43
43
|
}
|
|
44
44
|
]
|
|
@@ -101,7 +101,7 @@ RULES
|
|
|
101
101
|
- movable:false for section/slide/grid-item/popup; otherwise true. runtime is always {}.
|
|
102
102
|
- Every form input MUST have a unique specials.field_name.
|
|
103
103
|
- events item: { "id", "type", "action", "target", ...action-specific extra fields }. TRIGGER (type): click & hover on any element; success & error on a FORM (success = after a successful submit, error = on validation failure); delay on any element (when it scrolls into view); unset on init. Action vocab per trigger: click→CLICK_ACTIONS, hover→HOVER_ACTIONS, success→SUCCESS_ACTIONS, error→ERROR_ACTIONS, delay→DELAY_ACTIONS (all returned by get_generation_guide). For element-targeting actions (open_popup, close_popup, scroll_to, show_section, hide_section, show_hide_element, change_tab, collapse) target = the target element's id; open_link/download_file target = URL; open_sms/send_email/phone_call target = phone/email; copy target = text (or element id when copyType='elementValue'); set_field_value target = field_name; target may be null (e.g. animation_hover). Each action also reads extra fields (e.g. open_link→targetURL/delayTime, scroll_to→scrollMore, change_tab→moveTo/tabIndex, lightbox→typeLightbox/alt, show_hide_element→onlyMode, open_app→appTarget+provider fields, set_field_value→set_value) — see the action maps for the full list.
|
|
104
|
-
- ANIMATION: each breakpoint's config has config.animation = { "name":"none", "delay":0, "duration":3, "repeat":null }.
|
|
104
|
+
- ANIMATION: each breakpoint's config has config.animation = { "name":"none", "delay":0, "duration":3, "repeat":null }. Animations only run on these 9 element types: group, image-block, text-block, rectangle, button, countdown, line, list-paragraph, notify (renderer contract: landing_page_build/render/build/animate.js). Any other type with a non-"none" name renders stuck/dim in its pre-animation state — keep "none" on all other types. The name must be from the editor's animate.css set; common entrance families: fadeIn* (fadeInUp, fadeInDown, fadeInLeft, fadeInRight…), slideIn* (slideInUp, slideInDown, slideInLeft, slideInRight), zoomIn* (zoomIn, zoomInUp, zoomInDown…), bounceIn* (bounceIn, bounceInUp…), backIn* (backInDown, backInLeft…), flipIn* (flipInX, flipInY), lightSpeedIn* (lightSpeedInLeft, lightSpeedInRight), rotateIn* (rotateIn, rotateInDownLeft…), rollIn, jackInTheBox; attention seekers: bounce, pulse, tada, headShake, wobble, jello, heartBeat, rubberBand, shakeX, shakeY. The full set is enforced by validate_page — use an invalid name and the element renders stuck. NEVER set styles.opacity < 1 for a "subtle" or "muted" look — opacity is permanent and renders the element and all its content faded forever; use rgba() alpha on the color or background property instead.
|
|
105
105
|
- Real data the page DISPLAYS must come from the user — never invent it: phone/hotline/Zalo, price (+ original price), address, shop/brand name, links/URLs, email, opening hours, exact stats/social-proof numbers. If a value the page needs is missing, ASK for it (in intake, or pause before generating); use a clearly-labelled placeholder ONLY when the user explicitly declines, and tell them exactly what to fill. Write ALL page copy in the SAME language the user is chatting in (mirror it), with FULL, CORRECT diacritics/accents — for Vietnamese this means proper dấu (e.g. "Trân Trọng Kính Mời", "Ngày 15 Tháng 08 Năm 2025"), NEVER accent-stripped "không dấu" text. Do not romanize, transliterate, or drop accent marks from any language.
|
|
106
106
|
|
|
107
107
|
INTAKE — act as a DESIGN CONSULTANT, not a form. Goal: understand what the customer actually wants, then design as close to their intent as possible. Ask BEFORE generating, EVERY time (even a "quick"/"test" page).
|
|
@@ -14,10 +14,15 @@ RULES (follow for every request):
|
|
|
14
14
|
- create_page and update_page DEFAULT to dry_run=true (a safety net for ambiguous requests). When the user's intent is clear AND validate_page already passed (no errors), SKIP the dry-run and call with dry_run=false directly — saves one round-trip. Use dry_run=true only when (a) the request is ambiguous about target/content, (b) the user explicitly asks to "preview" or "xem trước", (c) this is an update_page that overwrites significant existing content, or (d) you genuinely need to inspect the redacted payload. Never loop dry-runs to "check" the source — validate_page is the validator. Do not run dry-run then dry-run again before the real write.
|
|
15
15
|
- LARGE PAGES (4+ sections) — build INCREMENTALLY to avoid the giant single create_page payload that can drop the connection: create_page with a SMALL skeleton (empty/near-empty page) to get a page_id, then call add_section once per section (each call ships ONLY that section; the backend appends it server-side and rejects duplicate ids — no whole-source get+put). Small pages can still go in one create_page pass.
|
|
16
16
|
- EDIT existing pages surgically: find_pages (locate the page by name/domain/id when you don't already have a page_id) → get_page → change ONLY what was asked → keep every other element, its id, and coordinates. For a SMALL edit, PREFER patch_page over update_page: send only the changed elements by id (ops: update/replace/remove/add) instead of re-shipping the whole tree — the MCP fetches the live source, merges, validates and saves server-side. Use update_page only when you're rewriting most of the page. Never regenerate the whole tree for a small change.
|
|
17
|
-
- FIX-AFTER-ERROR (don't rebuild — this is the #1 time-waster):
|
|
18
|
-
· create_page failed → its error returns a draft_id (
|
|
19
|
-
·
|
|
20
|
-
|
|
17
|
+
- FIX-AFTER-ERROR / RETRY-AFTER-TIMEOUT (don't rebuild — this is the #1 time-waster): every mutating tool (create_page, update_page, add_section, patch_page) writes the payload to a draft cache BEFORE the network call and returns a draft_id. On validation failure, timeout, or any network error, the draft is kept — retry or fix without re-sending the full JSON:
|
|
18
|
+
· create_page failed validation OR timed out → its error returns a draft_id (full source cached). Call patch_page({ draft_id, patches:[…], dry_run:false }) to fix ONLY the listed elements, or retry create_page({ draft_id, dry_run:false }) as-is after a timeout.
|
|
19
|
+
· add_section failed validation, timed out, or returned dry_run=true → response returns a draft_id (section batch cached). Call patch_page({ draft_id, patches:[…], dry_run:false }) to fix elements and append, or retry patch_page({ draft_id, dry_run:false }) (no patches) to commit as-is.
|
|
20
|
+
· update_page timed out or failed → response returns a draft_id (full source cached). Retry update_page({ draft_id, dry_run:false }) or patch_page({ draft_id, dry_run:false }) with no patches.
|
|
21
|
+
· patch_page (live-page) timed out → response returns a draft_id (patched source cached). Retry patch_page({ draft_id, dry_run:false }) with no patches — the COMMIT-AS-IS path.
|
|
22
|
+
· update_page validation failed → the page already has a page_id → patch_page({ page_id, patches:[…] }) the offending ids.
|
|
23
|
+
COMMIT-AS-IS (retry path after timeout): patch_page({ draft_id, dry_run:false }) with NO patches — skips the apply step, re-validates, then saves. This is the universal retry for any timed-out write.
|
|
24
|
+
A wrong element type is the most common error → { op:'update', id:'<element id>', type:'<allowed type>' } (run list_elements if unsure). Drafts expire in ~30 min.
|
|
25
|
+
- DRY-RUN CACHE: create_page, update_page, and add_section dry_run=true all cache the validated payload and return a draft_id. Re-run the same tool with { draft_id, dry_run:false } — no need to re-send the source.
|
|
21
26
|
- Organizations: call list_organizations and ask which to use; default to the is_default org. Endpoints are owner-scoped (only the account's own pages).
|
|
22
27
|
- REFERENCE INPUT — if the user provides a layout reference, USE it as the layout anchor (don't ignore it, don't re-invent from scratch). Three input modes: (1) IMAGE/screenshot attached in chat → analyze it natively (no tool call): identify section flow (hero/features/form/cta/footer), heading hierarchy, dominant colors, font feel, then map sections to Webcake elements. (2) HTML string → call ingest_html(html) to get a compact AST. (3) URL → call ingest_url(url) for the same AST. The AST classifies sections by role and lists headings/subheadings/ctas/images/form_fields plus brand hints (colors/fonts) — use it for LAYOUT + HIERARCHY, then generate FRESH content tailored to the user's brand (don't 1:1 copy text). intent='clone' only when the user explicitly asks to mirror the original; default intent='adapt'. The reference workflow PRESERVES craft rules above (centering, page margin, premium spacing, real images) — apply them on top of the reference layout, don't bypass them.
|
|
23
28
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
10
|
import Ajv2020Module from "ajv/dist/2020.js";
|
|
11
11
|
import { CONTAINER_TYPES, FIELD_TYPES } from "./elements/index.js";
|
|
12
|
+
import { ANIMATABLE_TYPES, ANIMATION_NAMES } from "./vocab.js";
|
|
12
13
|
// ajv ships as CJS; under Node16 ESM the constructor is on `.default`.
|
|
13
14
|
const Ajv2020 = Ajv2020Module.default ?? Ajv2020Module;
|
|
14
15
|
// Loaded at runtime (the build copies this JSON beside the compiled validator)
|
|
@@ -210,6 +211,48 @@ export function validatePage(input) {
|
|
|
210
211
|
});
|
|
211
212
|
}
|
|
212
213
|
}
|
|
214
|
+
// animation contract — checked per breakpoint
|
|
215
|
+
// Source: landing_page_build/render/build/animate.js (animatable type list)
|
|
216
|
+
// landing_page_backend/assets/editor/main/traits/TraitAnimation.vue (name set)
|
|
217
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
218
|
+
const anim = node.responsive?.[bp]?.config?.animation;
|
|
219
|
+
if (!anim || typeof anim !== "object")
|
|
220
|
+
continue;
|
|
221
|
+
const animName = anim.name;
|
|
222
|
+
if (typeof animName !== "string" || animName === "none")
|
|
223
|
+
continue;
|
|
224
|
+
// name is present and not 'none' — check type animatability first
|
|
225
|
+
if (type && !ANIMATABLE_TYPES.has(type)) {
|
|
226
|
+
errors.push(`${path} (${type}) [${bp}]: the renderer cannot animate type "${type}" — ` +
|
|
227
|
+
`the element will render stuck/dim in its pre-animation state. ` +
|
|
228
|
+
`Fix: patch_page setting config:{${bp}:{animation:{name:'none',delay:0,duration:3,repeat:null}}} ` +
|
|
229
|
+
`or move the animation onto an animatable wrapper (e.g. type "group").`);
|
|
230
|
+
}
|
|
231
|
+
// name must be in the known animate.css set
|
|
232
|
+
if (!ANIMATION_NAMES.has(animName)) {
|
|
233
|
+
errors.push(`${path} (${type ?? "?"}) [${bp}]: animation name "${animName}" is not in the editor's ` +
|
|
234
|
+
`animate.css set — the keyframe is unknown and the animation never runs. ` +
|
|
235
|
+
`Valid examples: fadeInUp, slideInLeft, zoomIn, bounceIn, backInDown, flipInX, lightSpeedInLeft, rotateIn, rollIn, jackInTheBox.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// styles.opacity < 1 renders the element permanently faded (exportCss.js emits opacity:<v>)
|
|
239
|
+
for (const bp of ["desktop", "mobile"]) {
|
|
240
|
+
const styles = node.responsive?.[bp]?.styles;
|
|
241
|
+
if (!styles || typeof styles !== "object")
|
|
242
|
+
continue;
|
|
243
|
+
const raw = styles.opacity;
|
|
244
|
+
if (raw === undefined || raw === null)
|
|
245
|
+
continue;
|
|
246
|
+
const v = typeof raw === "number" ? raw : typeof raw === "string" ? parseFloat(raw) : NaN;
|
|
247
|
+
if (!Number.isFinite(v))
|
|
248
|
+
continue; // non-numeric garbage → schema territory, skip
|
|
249
|
+
if (v < 1) {
|
|
250
|
+
warnings.push(`${path} (${type ?? "?"}) [${bp}]: styles.opacity=${v} — ` +
|
|
251
|
+
`the element will render permanently faded. ` +
|
|
252
|
+
`If unintended, fix via patch_page({op:'update',id:'${node.id ?? "?"}',styles:{${bp}:{opacity:1}}}); ` +
|
|
253
|
+
`for a muted color use rgba() alpha on the color/background property instead.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
213
256
|
// countdown.language must be a key the renderer's lang table knows (or 'custom');
|
|
214
257
|
// anything else (e.g. a locale code "vi"/"en") crashes the renderer with
|
|
215
258
|
// "is not iterable" when it destructures lang[language].
|
|
@@ -5,6 +5,47 @@
|
|
|
5
5
|
* Derived from assets/render_v4/event/index.js.
|
|
6
6
|
*/
|
|
7
7
|
export const CANVAS = { desktopWidth: 960, mobileWidth: 420, defaultSectionHeight: 800 };
|
|
8
|
+
/**
|
|
9
|
+
* Element types the runtime animator handles.
|
|
10
|
+
* Source: landing_page_build/render/build/animate.js — the switch statement
|
|
11
|
+
* that emits the animation CSS class only covers these 9 types. Any other type
|
|
12
|
+
* with config.animation.name != 'none' produces a broken CSS selector and the
|
|
13
|
+
* element stays in the pre-animation (dim/hidden) state permanently.
|
|
14
|
+
*/
|
|
15
|
+
export const ANIMATABLE_TYPES = new Set([
|
|
16
|
+
"group", "image-block", "text-block", "rectangle", "button",
|
|
17
|
+
"countdown", "line", "list-paragraph", "notify",
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Valid animation name values accepted by the editor and the renderer.
|
|
21
|
+
* Source: landing_page_backend/assets/editor/main/traits/TraitAnimation.vue
|
|
22
|
+
* (the animate.css-backed option list). Any name outside this set produces
|
|
23
|
+
* an unknown keyframe — the animation never runs and the element may render stuck.
|
|
24
|
+
*/
|
|
25
|
+
export const ANIMATION_NAMES = new Set([
|
|
26
|
+
"none",
|
|
27
|
+
"bounce", "flash", "pulse", "rubberBand", "shakeX", "shakeY", "headShake",
|
|
28
|
+
"swing", "swingCenter", "tada", "wobble", "jello", "heartBeat",
|
|
29
|
+
"backInDown", "backInLeft", "backInRight", "backInUp",
|
|
30
|
+
"backOutDown", "backOutLeft", "backOutRight", "backOutUp",
|
|
31
|
+
"bounceIn", "bounceInDown", "bounceInLeft", "bounceInRight", "bounceInUp",
|
|
32
|
+
"bounceOut", "bounceOutDown", "bounceOutLeft", "bounceOutRight", "bounceOutUp",
|
|
33
|
+
"fadeIn", "fadeInDown", "fadeInDownBig", "fadeInLeft", "fadeInLeftBig",
|
|
34
|
+
"fadeInRight", "fadeInRightBig", "fadeInUp", "fadeInUpBig",
|
|
35
|
+
"fadeInTopLeft", "fadeInTopRight", "fadeInBottomLeft", "fadeInBottomRight",
|
|
36
|
+
"fadeOut", "fadeOutDown", "fadeOutDownBig", "fadeOutLeft", "fadeOutLeftBig",
|
|
37
|
+
"fadeOutRight", "fadeOutRightBig", "fadeOutUp", "fadeOutUpBig",
|
|
38
|
+
"fadeOutTopLeft", "fadeOutTopRight", "fadeOutBottomRight", "fadeOutBottomLeft",
|
|
39
|
+
"flip", "flipInX", "flipInY", "flipOutX", "flipOutY",
|
|
40
|
+
"lightSpeedInRight", "lightSpeedInLeft", "lightSpeedOutRight", "lightSpeedOutLeft",
|
|
41
|
+
"rotateIn", "rotateInDownLeft", "rotateInDownRight", "rotateInUpLeft", "rotateInUpRight",
|
|
42
|
+
"rotateOut", "rotateOutDownLeft", "rotateOutDownRight", "rotateOutUpLeft", "rotateOutUpRight",
|
|
43
|
+
"hinge", "jackInTheBox", "rollIn", "rollOut",
|
|
44
|
+
"zoomIn", "zoomInDown", "zoomInLeft", "zoomInRight", "zoomInUp",
|
|
45
|
+
"zoomOut", "zoomOutDown", "zoomOutLeft", "zoomOutRight", "zoomOutUp",
|
|
46
|
+
"slideInDown", "slideInLeft", "slideInRight", "slideInUp",
|
|
47
|
+
"slideOutDown", "slideOutLeft", "slideOutRight", "slideOutUp",
|
|
48
|
+
]);
|
|
8
49
|
export const EVENT_TRIGGERS = ["click", "hover", "success", "error", "unset", "delay"];
|
|
9
50
|
// Click-trigger actions. "Extra:" lists the action-specific event-object fields
|
|
10
51
|
// the dispatcher reads beyond { id, type, action, target } (render_v4/event/index.js).
|
|
@@ -1,11 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tiny in-memory store for
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Tiny in-memory store for sources that need a cache — three kinds:
|
|
3
|
+
*
|
|
4
|
+
* - 'page' (default/absent): a full page source whose create_page FAILED validation
|
|
5
|
+
* OR whose create_page network call failed/timed-out after validation passed.
|
|
6
|
+
* The create-before-save gap: a failed create has no page_id to patch
|
|
7
|
+
* against, so we hold the source here keyed by a random draft_id.
|
|
8
|
+
* Commit path: create_page({draft_id, dry_run:false}) or
|
|
9
|
+
* patch_page({draft_id, patches?, dry_run:false}).
|
|
10
|
+
*
|
|
11
|
+
* - 'sections': the expanded throwaway shell built by add_section when dry_run=true
|
|
12
|
+
* or when section validation fails / the append network call fails, so the
|
|
13
|
+
* model never has to re-send the section payload between dry-run → real call
|
|
14
|
+
* or after a fix/timeout round.
|
|
15
|
+
* `source` holds the shell { page:[<new sections>], … }; `page_id` is
|
|
16
|
+
* the live page the sections will be appended to.
|
|
17
|
+
* Commit path: add_section({page_id, draft_id, dry_run:false}) or
|
|
18
|
+
* patch_page({draft_id, patches?, dry_run:false}).
|
|
19
|
+
*
|
|
20
|
+
* - 'update' : a full page source for updatePageSource on an EXISTING page whose
|
|
21
|
+
* update_page/patch_page network call failed or timed-out after validation
|
|
22
|
+
* passed. `page_id` is the live page to overwrite.
|
|
23
|
+
* Commit path: update_page({draft_id, dry_run:false}) or
|
|
24
|
+
* patch_page({draft_id, patches?, dry_run:false}).
|
|
6
25
|
*
|
|
7
26
|
* Bounded + TTL'd; a lost draft (process restart, eviction, expiry) just means the
|
|
8
|
-
* model falls back to re-sending the full source
|
|
27
|
+
* model falls back to re-sending the full source — never a failure.
|
|
9
28
|
* Process-global, but draft_ids are random/unguessable AND persisting still uses the
|
|
10
29
|
* CALLER's own creds, so a draft only ever yields a page in the caller's account.
|
|
11
30
|
*/
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
* route is served by this same server in `serve` mode (see src/http.ts).
|
|
19
19
|
*/
|
|
20
20
|
const PEXELS_SEARCH_ENDPOINT = "https://api.pexels.com/v1/search";
|
|
21
|
+
/** Fetch timeout for Pexels/proxy calls — matches the Webcake client default. */
|
|
22
|
+
const PEXELS_TIMEOUT_MS = (() => {
|
|
23
|
+
const v = parseInt(process.env.WEBCAKE_HTTP_TIMEOUT_MS ?? "", 10);
|
|
24
|
+
return Number.isFinite(v) && v > 0 ? v : 60_000;
|
|
25
|
+
})();
|
|
21
26
|
/** Default hosted proxy that holds a shared Pexels key (override with PEXELS_PROXY_BASE). */
|
|
22
27
|
export const PEXELS_PROXY_DEFAULT = "https://mcp.toolvn.io.vn";
|
|
23
28
|
/** Path of the proxy's image-search route (served by this server in `serve` mode). */
|
|
@@ -72,9 +77,12 @@ export async function searchPexels(key, params) {
|
|
|
72
77
|
const url = `${PEXELS_SEARCH_ENDPOINT}?${buildSearchQuery(params).toString()}`;
|
|
73
78
|
let res;
|
|
74
79
|
try {
|
|
75
|
-
res = await fetch(url, { method: "GET", headers: { Authorization: key } });
|
|
80
|
+
res = await fetch(url, { method: "GET", headers: { Authorization: key }, signal: AbortSignal.timeout(PEXELS_TIMEOUT_MS) });
|
|
76
81
|
}
|
|
77
82
|
catch (e) {
|
|
83
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
84
|
+
return { ok: false, status: 0, error: `request timed out after ${PEXELS_TIMEOUT_MS}ms calling Pexels` };
|
|
85
|
+
}
|
|
78
86
|
return { ok: false, status: 0, error: `Network error calling Pexels: ${e?.message ?? e}` };
|
|
79
87
|
}
|
|
80
88
|
const body = await res.text();
|
|
@@ -102,9 +110,12 @@ export async function searchImagesViaProxy(base, params) {
|
|
|
102
110
|
const url = `${base.replace(/\/+$/, "")}${PEXELS_PROXY_PATH}?${buildSearchQuery(params).toString()}`;
|
|
103
111
|
let res;
|
|
104
112
|
try {
|
|
105
|
-
res = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
|
|
113
|
+
res = await fetch(url, { method: "GET", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(PEXELS_TIMEOUT_MS) });
|
|
106
114
|
}
|
|
107
115
|
catch (e) {
|
|
116
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
117
|
+
return { ok: false, status: 0, error: `request timed out after ${PEXELS_TIMEOUT_MS}ms calling image proxy ${base}` };
|
|
118
|
+
}
|
|
108
119
|
return { ok: false, status: 0, error: `Network error calling image proxy ${base}: ${e?.message ?? e}` };
|
|
109
120
|
}
|
|
110
121
|
const body = await res.text();
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/** Default fetch timeout in ms. Override via WEBCAKE_HTTP_TIMEOUT_MS env. */
|
|
2
|
+
const HTTP_TIMEOUT_MS = (() => {
|
|
3
|
+
const v = parseInt(process.env.WEBCAKE_HTTP_TIMEOUT_MS ?? "", 10);
|
|
4
|
+
return Number.isFinite(v) && v > 0 ? v : 60_000;
|
|
5
|
+
})();
|
|
6
|
+
/** Build an AbortSignal that fires after `ms` milliseconds. */
|
|
7
|
+
function timeoutSignal(ms) {
|
|
8
|
+
return AbortSignal.timeout(ms);
|
|
9
|
+
}
|
|
10
|
+
/** Wrap a fetch error or AbortError in the standard {ok:false} shape. */
|
|
11
|
+
function timeoutOrNetworkError(url, e) {
|
|
12
|
+
if (e?.name === "TimeoutError" || e?.name === "AbortError") {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
status: 0,
|
|
16
|
+
error: `request timed out after ${HTTP_TIMEOUT_MS}ms — the backend may still complete it; check before re-creating to avoid duplicates`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { ok: false, status: 0, error: `Network error calling ${url}: ${e?.message ?? e}` };
|
|
20
|
+
}
|
|
1
21
|
const CREATE_ENDPOINT = "/api/v1/ai/create_page_from_source";
|
|
2
22
|
const ORGS_ENDPOINT = "/api/v1/org/organizations";
|
|
3
23
|
const PAGES_ENDPOINT = "/api/v1/ai/pages";
|
|
@@ -103,10 +123,10 @@ export async function listOrganizations(config) {
|
|
|
103
123
|
const url = `${config.base}${ORGS_ENDPOINT}`;
|
|
104
124
|
let res;
|
|
105
125
|
try {
|
|
106
|
-
res = await fetch(url, { method: "GET", headers: authHeaders(config) });
|
|
126
|
+
res = await fetch(url, { method: "GET", headers: authHeaders(config), signal: timeoutSignal(HTTP_TIMEOUT_MS) });
|
|
107
127
|
}
|
|
108
128
|
catch (e) {
|
|
109
|
-
return
|
|
129
|
+
return timeoutOrNetworkError(url, e);
|
|
110
130
|
}
|
|
111
131
|
const text = await res.text();
|
|
112
132
|
let json = null;
|
|
@@ -134,10 +154,10 @@ export async function createPage(config, name, source, orgId) {
|
|
|
134
154
|
const req = buildRequest(config, name, source, orgId);
|
|
135
155
|
let res;
|
|
136
156
|
try {
|
|
137
|
-
res = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body });
|
|
157
|
+
res = await fetch(req.url, { method: req.method, headers: req.headers, body: req.body, signal: timeoutSignal(HTTP_TIMEOUT_MS) });
|
|
138
158
|
}
|
|
139
159
|
catch (e) {
|
|
140
|
-
return
|
|
160
|
+
return timeoutOrNetworkError(req.url, e);
|
|
141
161
|
}
|
|
142
162
|
const text = await res.text();
|
|
143
163
|
let json = null;
|
|
@@ -179,10 +199,11 @@ export async function createPage(config, name, source, orgId) {
|
|
|
179
199
|
async function getJson(url, config) {
|
|
180
200
|
let res;
|
|
181
201
|
try {
|
|
182
|
-
res = await fetch(url, { method: "GET", headers: authHeaders(config) });
|
|
202
|
+
res = await fetch(url, { method: "GET", headers: authHeaders(config), signal: timeoutSignal(HTTP_TIMEOUT_MS) });
|
|
183
203
|
}
|
|
184
204
|
catch (e) {
|
|
185
|
-
|
|
205
|
+
const e2 = timeoutOrNetworkError(url, e);
|
|
206
|
+
return { ok: false, status: 0, json: null, text: "", error: e2.error };
|
|
186
207
|
}
|
|
187
208
|
const text = await res.text();
|
|
188
209
|
let json = null;
|
|
@@ -279,10 +300,11 @@ export async function appendSection(config, pageId, sections) {
|
|
|
279
300
|
method: "POST",
|
|
280
301
|
headers: authHeaders(config),
|
|
281
302
|
body: JSON.stringify({ page_id: pageId, sections }),
|
|
303
|
+
signal: timeoutSignal(HTTP_TIMEOUT_MS),
|
|
282
304
|
});
|
|
283
305
|
}
|
|
284
306
|
catch (e) {
|
|
285
|
-
return
|
|
307
|
+
return timeoutOrNetworkError(url, e);
|
|
286
308
|
}
|
|
287
309
|
// No such route on an older backend → Phoenix 404. Signal a fallback.
|
|
288
310
|
if (res.status === 404) {
|
|
@@ -328,10 +350,11 @@ export async function updatePageSource(config, pageId, source) {
|
|
|
328
350
|
method: "POST",
|
|
329
351
|
headers: authHeaders(config),
|
|
330
352
|
body: JSON.stringify({ page_id: pageId, source }),
|
|
353
|
+
signal: timeoutSignal(HTTP_TIMEOUT_MS),
|
|
331
354
|
});
|
|
332
355
|
}
|
|
333
356
|
catch (e) {
|
|
334
|
-
return
|
|
357
|
+
return timeoutOrNetworkError(url, e);
|
|
335
358
|
}
|
|
336
359
|
const text = await res.text();
|
|
337
360
|
let json = null;
|
|
@@ -392,7 +415,7 @@ export function buildPublishRequestRedacted(config, pageId, sourceString, opts =
|
|
|
392
415
|
async function postToHost(url, headers, body) {
|
|
393
416
|
const u = new URL(url);
|
|
394
417
|
if (!u.hostname.endsWith(".localhost")) {
|
|
395
|
-
const res = await fetch(url, { method: "POST", headers, body });
|
|
418
|
+
const res = await fetch(url, { method: "POST", headers, body, signal: timeoutSignal(HTTP_TIMEOUT_MS) });
|
|
396
419
|
return { status: res.status, text: await res.text() };
|
|
397
420
|
}
|
|
398
421
|
const { request } = await import("node:http");
|
|
@@ -408,6 +431,9 @@ async function postToHost(url, headers, body) {
|
|
|
408
431
|
res.on("data", (c) => (data += c));
|
|
409
432
|
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: data }));
|
|
410
433
|
});
|
|
434
|
+
req.setTimeout(HTTP_TIMEOUT_MS, () => {
|
|
435
|
+
req.destroy(new Error(`request timed out after ${HTTP_TIMEOUT_MS}ms`));
|
|
436
|
+
});
|
|
411
437
|
req.on("error", reject);
|
|
412
438
|
req.end(body);
|
|
413
439
|
});
|
|
@@ -429,7 +455,8 @@ export async function publishPage(config, pageId, sourceString, opts = {}) {
|
|
|
429
455
|
({ status, text } = await postToHost(url, { ...authHeaders(config), Accept: "*/*" }, publishBody(sourceString, opts)));
|
|
430
456
|
}
|
|
431
457
|
catch (e) {
|
|
432
|
-
|
|
458
|
+
const e2 = timeoutOrNetworkError(url, e);
|
|
459
|
+
return { ok: false, status: e2.status, error: e2.error };
|
|
433
460
|
}
|
|
434
461
|
let json = null;
|
|
435
462
|
try {
|