@avocadostudio-ai/orchestrator-core 0.1.0
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/LICENSE +201 -0
- package/dist/agent/agent-context.d.ts +19 -0
- package/dist/agent/agent-context.js +67 -0
- package/dist/agent/agent-logger.d.ts +5 -0
- package/dist/agent/agent-logger.js +22 -0
- package/dist/agent/agent-loop-openai.d.ts +8 -0
- package/dist/agent/agent-loop-openai.js +172 -0
- package/dist/agent/agent-loop.d.ts +56 -0
- package/dist/agent/agent-loop.js +167 -0
- package/dist/agent/agent-provider.d.ts +28 -0
- package/dist/agent/agent-provider.js +63 -0
- package/dist/agent/agent-tools.d.ts +28 -0
- package/dist/agent/agent-tools.js +899 -0
- package/dist/agent/context/editing-guidelines.md +46 -0
- package/dist/agent/context/role.md +39 -0
- package/dist/agent/integration-prompt.d.ts +9 -0
- package/dist/agent/integration-prompt.js +154 -0
- package/dist/agent/sites-agent-context.d.ts +12 -0
- package/dist/agent/sites-agent-context.js +316 -0
- package/dist/agent/sites-agent-shared.d.ts +161 -0
- package/dist/agent/sites-agent-shared.js +1101 -0
- package/dist/agent/sites-agent-tools.d.ts +18 -0
- package/dist/agent/sites-agent-tools.js +1227 -0
- package/dist/chat/anthropic-cache.d.ts +20 -0
- package/dist/chat/anthropic-cache.js +54 -0
- package/dist/chat/anthropic-planner.d.ts +98 -0
- package/dist/chat/anthropic-planner.js +1012 -0
- package/dist/chat/changelog-coverage-validator.d.ts +37 -0
- package/dist/chat/changelog-coverage-validator.js +215 -0
- package/dist/chat/chat-pipeline-context.d.ts +211 -0
- package/dist/chat/chat-pipeline-context.js +249 -0
- package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
- package/dist/chat/chat-pipeline-deterministic.js +407 -0
- package/dist/chat/chat-pipeline-image.d.ts +86 -0
- package/dist/chat/chat-pipeline-image.js +897 -0
- package/dist/chat/chat-pipeline-shared.d.ts +69 -0
- package/dist/chat/chat-pipeline-shared.js +212 -0
- package/dist/chat/chat-pipeline-translation.d.ts +27 -0
- package/dist/chat/chat-pipeline-translation.js +417 -0
- package/dist/chat/chat-pipeline-ui.d.ts +14 -0
- package/dist/chat/chat-pipeline-ui.js +244 -0
- package/dist/chat/chat-pipeline.d.ts +99 -0
- package/dist/chat/chat-pipeline.js +3999 -0
- package/dist/chat/decomposer.d.ts +21 -0
- package/dist/chat/decomposer.js +65 -0
- package/dist/chat/gemini-planner.d.ts +70 -0
- package/dist/chat/gemini-planner.js +541 -0
- package/dist/chat/hallucination-validator.d.ts +36 -0
- package/dist/chat/hallucination-validator.js +110 -0
- package/dist/chat/locale-strings.d.ts +47 -0
- package/dist/chat/locale-strings.js +100 -0
- package/dist/chat/plan-json-schema.d.ts +133 -0
- package/dist/chat/plan-json-schema.js +112 -0
- package/dist/chat/planner-types.d.ts +120 -0
- package/dist/chat/planner-types.js +66 -0
- package/dist/chat/planner.d.ts +148 -0
- package/dist/chat/planner.js +1361 -0
- package/dist/chat/prompts.d.ts +67 -0
- package/dist/chat/prompts.js +356 -0
- package/dist/chat/provider-routing.d.ts +14 -0
- package/dist/chat/provider-routing.js +27 -0
- package/dist/chat/variation-pipeline.d.ts +135 -0
- package/dist/chat/variation-pipeline.js +837 -0
- package/dist/chat/vision-alt-generator.d.ts +35 -0
- package/dist/chat/vision-alt-generator.js +152 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +17 -0
- package/dist/cms/bootstrap.js +85 -0
- package/dist/cms/editor-api-adapter.d.ts +23 -0
- package/dist/cms/editor-api-adapter.js +71 -0
- package/dist/cms/index.d.ts +4 -0
- package/dist/cms/index.js +3 -0
- package/dist/cms/json-file-adapter.d.ts +11 -0
- package/dist/cms/json-file-adapter.js +62 -0
- package/dist/demo-mode.d.ts +59 -0
- package/dist/demo-mode.js +201 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.js +129 -0
- package/dist/http/chat-stream-resumable.d.ts +108 -0
- package/dist/http/chat-stream-resumable.js +290 -0
- package/dist/http/chat-stream.d.ts +99 -0
- package/dist/http/chat-stream.js +92 -0
- package/dist/image/gdrive-client.d.ts +22 -0
- package/dist/image/gdrive-client.js +215 -0
- package/dist/image/image-helpers.d.ts +95 -0
- package/dist/image/image-helpers.js +488 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/jira/jira-approval.d.ts +22 -0
- package/dist/jira/jira-approval.js +51 -0
- package/dist/jira/jira-client.d.ts +44 -0
- package/dist/jira/jira-client.js +313 -0
- package/dist/jira/jira-poller.d.ts +46 -0
- package/dist/jira/jira-poller.js +184 -0
- package/dist/jira/jira-processor.d.ts +103 -0
- package/dist/jira/jira-processor.js +1085 -0
- package/dist/jira/jira-types.d.ts +117 -0
- package/dist/jira/jira-types.js +38 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +28 -0
- package/dist/migration/mcp-server-stdio.d.ts +8 -0
- package/dist/migration/mcp-server-stdio.js +672 -0
- package/dist/migration/migration-prompt.d.ts +7 -0
- package/dist/migration/migration-prompt.js +197 -0
- package/dist/migration/migration-tools.d.ts +17 -0
- package/dist/migration/migration-tools.js +159 -0
- package/dist/migration/scrape-cache.d.ts +9 -0
- package/dist/migration/scrape-cache.js +19 -0
- package/dist/nlp/deterministic-planner-context.d.ts +141 -0
- package/dist/nlp/deterministic-planner-context.js +362 -0
- package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
- package/dist/nlp/deterministic-planner-pages.js +170 -0
- package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
- package/dist/nlp/deterministic-planner-patches.js +508 -0
- package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
- package/dist/nlp/deterministic-planner-refs.js +164 -0
- package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
- package/dist/nlp/deterministic-planner-suggestions.js +579 -0
- package/dist/nlp/deterministic-planner.d.ts +85 -0
- package/dist/nlp/deterministic-planner.js +1631 -0
- package/dist/nlp/intent-detection.d.ts +309 -0
- package/dist/nlp/intent-detection.js +730 -0
- package/dist/nlp/intent-helpers.d.ts +15 -0
- package/dist/nlp/intent-helpers.js +243 -0
- package/dist/nlp/intent-patterns.d.ts +40 -0
- package/dist/nlp/intent-patterns.js +223 -0
- package/dist/nlp/plan-normalizer.d.ts +41 -0
- package/dist/nlp/plan-normalizer.js +1537 -0
- package/dist/ops/destructive-action-gate.d.ts +44 -0
- package/dist/ops/destructive-action-gate.js +90 -0
- package/dist/ops/ops-engine.d.ts +151 -0
- package/dist/ops/ops-engine.js +1394 -0
- package/dist/publish/diff-engine.d.ts +18 -0
- package/dist/publish/diff-engine.js +305 -0
- package/dist/publish/publish-helpers.d.ts +87 -0
- package/dist/publish/publish-helpers.js +521 -0
- package/dist/publish/publish-target-registry.d.ts +7 -0
- package/dist/publish/publish-target-registry.js +61 -0
- package/dist/publish/publish-target.d.ts +81 -0
- package/dist/publish/publish-target.js +1 -0
- package/dist/publish/targets/deploy-hook.d.ts +13 -0
- package/dist/publish/targets/deploy-hook.js +123 -0
- package/dist/publish/targets/git.d.ts +13 -0
- package/dist/publish/targets/git.js +55 -0
- package/dist/publish/targets/site-contract.d.ts +19 -0
- package/dist/publish/targets/site-contract.js +124 -0
- package/dist/state/content-source.d.ts +17 -0
- package/dist/state/content-source.js +1 -0
- package/dist/state/in-memory-content-source.d.ts +27 -0
- package/dist/state/in-memory-content-source.js +51 -0
- package/dist/state/session-lock.d.ts +13 -0
- package/dist/state/session-lock.js +29 -0
- package/dist/state/session-state.d.ts +310 -0
- package/dist/state/session-state.js +1083 -0
- package/dist/state/sqlite-store-singleton.d.ts +31 -0
- package/dist/state/sqlite-store-singleton.js +170 -0
- package/dist/state/sqlite-store.d.ts +135 -0
- package/dist/state/sqlite-store.js +421 -0
- package/dist/telemetry/chat-telemetry.d.ts +105 -0
- package/dist/telemetry/chat-telemetry.js +247 -0
- package/dist/telemetry/eval-candidate-store.d.ts +50 -0
- package/dist/telemetry/eval-candidate-store.js +120 -0
- package/dist/telemetry/feedback-store.d.ts +34 -0
- package/dist/telemetry/feedback-store.js +76 -0
- package/dist/telemetry/jira-telemetry.d.ts +57 -0
- package/dist/telemetry/jira-telemetry.js +68 -0
- package/dist/telemetry/migration-telemetry.d.ts +35 -0
- package/dist/telemetry/migration-telemetry.js +40 -0
- package/dist/telemetry/usage.d.ts +24 -0
- package/dist/telemetry/usage.js +80 -0
- package/dist/tools/builtin-registrations.d.ts +12 -0
- package/dist/tools/builtin-registrations.js +33 -0
- package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
- package/dist/tools/builtins/gdrive-browse.js +68 -0
- package/dist/tools/builtins/image-generate.d.ts +3 -0
- package/dist/tools/builtins/image-generate.js +211 -0
- package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
- package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
- package/dist/tools/builtins/unsplash-search.d.ts +3 -0
- package/dist/tools/builtins/unsplash-search.js +74 -0
- package/dist/tools/executor.d.ts +23 -0
- package/dist/tools/executor.js +169 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/registry.d.ts +21 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/runtime.d.ts +27 -0
- package/dist/tools/runtime.js +48 -0
- package/dist/tools/schema-validator.d.ts +24 -0
- package/dist/tools/schema-validator.js +88 -0
- package/dist/tools/types.d.ts +86 -0
- package/dist/tools/types.js +1 -0
- package/dist/variation-images.d.ts +19 -0
- package/dist/variation-images.js +12 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1631 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { allowedBlockTypes, getBlockMeta, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
|
|
3
|
+
import { extractRouteMentions, normalizeRouteCandidate, parseCreatePageRequest, requestsContentGeneration, toSeedSlug } from "./intent-helpers.js";
|
|
4
|
+
import { isBatchAddRequest, isFieldContentUpdateRequest, stripSiteContextEnvelope, stripPositionalAnchorPhrase, extractMentionedBlockTypes, hasCrossPageScope } from "./intent-detection.js";
|
|
5
|
+
import { defaultPropsForType, inferBlockTypeFromText, nextBlockId } from "./plan-normalizer.js";
|
|
6
|
+
import { getSessionDraft, getSiteConfig } from "../state/session-state.js";
|
|
7
|
+
import { inferAddedBlockTypeFromMessage, resolveBlockRef, ordinalToIndex, resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
|
|
8
|
+
import { EXCEPT_PATTERN, THIS_ONE_PATTERN } from "./intent-patterns.js";
|
|
9
|
+
import { extractAudienceTargets, titleCaseWords, audiencePatchForBlock, coercePatchForBlock, inferSimpleFieldPatchFromMessage, isRewriteRequest, shouldKeepRichTextTitleOnTranslate, inferFieldHintFromMessage, coercePatchForEditablePath, quotedText, buildListAppendPatch } from "./deterministic-planner-patches.js";
|
|
10
|
+
import { userFacingPropNames } from "./deterministic-planner-suggestions.js";
|
|
11
|
+
import { st } from "../chat/locale-strings.js";
|
|
12
|
+
import { buildCreatePagePlan, isPageRouteRenameRequest } from "./deterministic-planner-pages.js";
|
|
13
|
+
const CONTAINER_BLOCK_TYPES = /\b(carousel|slideshow|slider|cardgrid|card\s*grid|faqaccordion|faq|testimonials?|featuregrid|feature\s*grid|gallery|tabs?|table)\b/i;
|
|
14
|
+
// User-facing block label ("FAQ Accordion", not the raw "FAQAccordion" type).
|
|
15
|
+
// change_log/summary text ships on the MCP + HTTP API and persists into chat
|
|
16
|
+
// history, so it must read cleanly. Mirrors describeOp in the changelog validator.
|
|
17
|
+
// (Raw block IDs interpolated here used to be blanked by the id-stripper, leaving
|
|
18
|
+
// "Removed ." / "Appended item to ." — issue #14a; raw types leaked as
|
|
19
|
+
// "FAQAccordion" — #15a.)
|
|
20
|
+
function blockLabel(type) {
|
|
21
|
+
return getBlockMeta(type)?.displayName ?? type;
|
|
22
|
+
}
|
|
23
|
+
// "tab"/"slide"/"row" are LIST ITEMS that only exist inside a container block, so
|
|
24
|
+
// "add a tab …" / "add a slide …" map to the container's type. Without this the
|
|
25
|
+
// generic block-type scan picks an incidental label keyword ("a tab called
|
|
26
|
+
// Pricing" → CardGrid) instead of Tabs (#22). "stat" is covered by the existing
|
|
27
|
+
// Stats keyword. Anchored to "add … <item>" so it doesn't fire on prose mentions.
|
|
28
|
+
const ITEM_WORD_TO_CONTAINER = [
|
|
29
|
+
{ test: /\b(?:add|insert)\b[^.]*?\btabs?\b/, type: "Tabs" },
|
|
30
|
+
{ test: /\b(?:add|insert)\b[^.]*?\bslides?\b/, type: "Carousel" },
|
|
31
|
+
{ test: /\b(?:add|insert)\b[^.]*?\brows?\b/, type: "Table" }
|
|
32
|
+
];
|
|
33
|
+
function inferItemContainerType(message) {
|
|
34
|
+
const lower = message.toLowerCase();
|
|
35
|
+
return ITEM_WORD_TO_CONTAINER.find((e) => e.test.test(lower))?.type;
|
|
36
|
+
}
|
|
37
|
+
// Container nouns that inferBlockTypeFromText doesn't recognise on their own ("the
|
|
38
|
+
// classes grid" → CardGrid, "the faq accordion" → FAQAccordion). Used when resolving
|
|
39
|
+
// the TARGET of an "add X to the <container>" phrase (#28).
|
|
40
|
+
const CONTAINER_NOUN_TO_TYPE = [
|
|
41
|
+
{ test: /\bgrids?\b/, type: "CardGrid" },
|
|
42
|
+
{ test: /\baccordion\b/, type: "FAQAccordion" },
|
|
43
|
+
{ test: /\bcarousel\b|\bsliders?\b/, type: "Carousel" },
|
|
44
|
+
{ test: /\bgaller(?:y|ies)\b/, type: "Gallery" },
|
|
45
|
+
{ test: /\btabs?\b/, type: "Tabs" },
|
|
46
|
+
{ test: /\btables?\b/, type: "Table" }
|
|
47
|
+
];
|
|
48
|
+
function inferContainerTypeFromNoun(phrase) {
|
|
49
|
+
const lower = phrase.toLowerCase();
|
|
50
|
+
return CONTAINER_NOUN_TO_TYPE.find((e) => e.test.test(lower))?.type;
|
|
51
|
+
}
|
|
52
|
+
/** Detects "remove X from/in [container]" or "remove X from slides/items/cards" — intra-block, not page-level */
|
|
53
|
+
function isContainerScopedRemove(message) {
|
|
54
|
+
const lower = message.toLowerCase();
|
|
55
|
+
if (/\b(?:from|in|within|inside)\b/.test(lower) && CONTAINER_BLOCK_TYPES.test(lower))
|
|
56
|
+
return true;
|
|
57
|
+
if (/\b(?:from|in|within)\s+(?:all\s+)?(?:slides?|items?|cards?|questions?|entries?|rows?|features?)\b/.test(lower))
|
|
58
|
+
return true;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
export const blockTypeEnum = z.enum(allowedBlockTypes);
|
|
62
|
+
export const intentSchema = z.object({
|
|
63
|
+
action: z.enum(["add", "move", "update", "remove", "info", "clarify"]),
|
|
64
|
+
target_block_ref: z.string().min(1).nullish(),
|
|
65
|
+
target_block_type: blockTypeEnum.nullish(),
|
|
66
|
+
new_block_type: blockTypeEnum.nullish(),
|
|
67
|
+
position: z.enum(["top", "bottom", "before", "after"]).nullish(),
|
|
68
|
+
anchor_block_ref: z.string().min(1).nullish(),
|
|
69
|
+
patch: z.record(z.string(), z.unknown()).nullish(),
|
|
70
|
+
summary: z.string().min(1).nullish(),
|
|
71
|
+
assumption: z.string().min(1).nullish(),
|
|
72
|
+
complexity: z.enum(["simple", "standard", "complex"]).nullish()
|
|
73
|
+
});
|
|
74
|
+
/** Returns true when an add-block message carries content direction beyond the block type name. */
|
|
75
|
+
function hasContentDirective(message) {
|
|
76
|
+
const lower = message.toLowerCase();
|
|
77
|
+
// Phrases that signal the user wants specific content, not just a default block
|
|
78
|
+
return /\b(about|featuring|directing|promoting|highlighting|explaining|describing|marketing|showcasing|illustrating|linking to|that\s+(links?|points?|directs?|promotes?|describes?|shows?|explains?))\b/i.test(lower)
|
|
79
|
+
|| /\bfor\b.*\b(audience|users?|visitors?|customers?|readers?)\b/i.test(lower)
|
|
80
|
+
|| /\bwith\b.*\b(content|text|copy|images?|questions?|items?|cards?|testimonials?|features?)\b/i.test(lower)
|
|
81
|
+
|| /\b(proper|real|good|relevant|meaningful|appropriate|custom|tailored|compelling|engaging)\b/i.test(lower);
|
|
82
|
+
}
|
|
83
|
+
function isHeroLayoutRequest(message) {
|
|
84
|
+
const lower = message.toLowerCase();
|
|
85
|
+
if (!/\b(hero|banner)\b/.test(lower))
|
|
86
|
+
return false;
|
|
87
|
+
const mentionsVisual = /\b(image|photo|picture|text|copy|content)\b/.test(lower);
|
|
88
|
+
const mentionsSide = /\b(left|right|left-side|right-side)\b/.test(lower);
|
|
89
|
+
const mentionsSwap = /\b(swap|flip|reverse)\b/.test(lower) && /\b(image|photo|picture)\b/.test(lower) && /\b(text|copy|content)\b/.test(lower);
|
|
90
|
+
return (mentionsVisual && mentionsSide) || mentionsSwap;
|
|
91
|
+
}
|
|
92
|
+
function hasQuotedConstraintOnlyText(message) {
|
|
93
|
+
if (!quotedText(message))
|
|
94
|
+
return false;
|
|
95
|
+
return (/\b(?:avoid|without|exclude|ban|forbid|never use|do not use|don't use)\b[^.\n]{0,140}["'][^"']+["']/i.test(message) ||
|
|
96
|
+
/\b(?:avoid|without|exclude)\b[^.\n]{0,140}\b(?:cliche|cliches|buzzword|buzzwords)\b[^.\n]{0,140}["'][^"']+["']/i.test(message));
|
|
97
|
+
}
|
|
98
|
+
function hasQuotedReplacementDirective(message) {
|
|
99
|
+
return /\b(?:set|change|update|edit|replace|rewrite|reword|rephrase)\b[^.\n]{0,160}\b(?:to|as|with)\b[^.\n]{0,40}["'][^"']+["']/i.test(message);
|
|
100
|
+
}
|
|
101
|
+
// Whole-section/block nouns that make "hide X" mean "remove the block" (#31).
|
|
102
|
+
const HIDE_BLOCK_NOUN = /\b(sections?|blocks?|grids?|card\s*grids?|feature\s*grids?|testimonials?|hero|cta|faq|accordion|carousel|gallery|banner|two[\s-]?columns?|tabs?|table)\b/;
|
|
103
|
+
// A field noun means "hide X" targets something INSIDE a block (its image, copy,
|
|
104
|
+
// CTA, …), not the block itself — must NOT route to the destructive block delete.
|
|
105
|
+
const HIDE_FIELD_NOUN = /\b(image|images|photo|photos|picture|pictures|logo|logos|icon|icons|heading|headline|subheading|subtitle|caption|captions|label|labels|price|prices|paragraph|paragraphs|button|buttons|link|links|field|fields)\b/;
|
|
106
|
+
// A block noun used as the object of a locative preposition ("… on/in the hero")
|
|
107
|
+
// is the CONTAINER of the real target, not the target — defer.
|
|
108
|
+
const HIDE_FIELD_IN_BLOCK = /\b(?:on|in|of|from|within|inside)\s+(?:the\s+)?(?:sections?|blocks?|grids?|card\s*grids?|feature\s*grids?|testimonials?|hero|cta|faq|accordion|carousel|gallery|banner|two[\s-]?columns?|tabs?|table)\b/;
|
|
109
|
+
function inferActionFromMessage(message) {
|
|
110
|
+
const lower = message.toLowerCase();
|
|
111
|
+
const hasPageCreateCue = Boolean(parseCreatePageRequest(message));
|
|
112
|
+
if (isHeroLayoutRequest(lower))
|
|
113
|
+
return "update";
|
|
114
|
+
if (hasPageCreateCue)
|
|
115
|
+
return "add";
|
|
116
|
+
// "add X in/to [the Y] heading/subheading/copy/..." → update to a field, not add.
|
|
117
|
+
// Must run before the generic verb-position pass so "add" does not win.
|
|
118
|
+
if (isFieldContentUpdateRequest(message))
|
|
119
|
+
return "update";
|
|
120
|
+
// "hide the third section" / "hide the testimonials block" — the editor has no
|
|
121
|
+
// visibility toggle, so hiding a whole section means removing it. Route "hide" to
|
|
122
|
+
// remove ONLY when it targets a WHOLE section/block — never a field inside one.
|
|
123
|
+
// "hide the hero image" / "hide the hero subheading" / "hide the cta on the hero"
|
|
124
|
+
// all name a block noun but mean "clear a field", and routing them here deletes
|
|
125
|
+
// the entire block at high confidence (auto-applied). When a field noun appears,
|
|
126
|
+
// or a block noun is the object of a locative preposition ("… on/in the hero"),
|
|
127
|
+
// the target is something INSIDE the block, so defer to the LLM instead of the
|
|
128
|
+
// destructive block-removal path. (#31, and the field-hide regression.)
|
|
129
|
+
if (/\bhide\b/.test(lower) && HIDE_BLOCK_NOUN.test(lower) && !HIDE_FIELD_NOUN.test(lower) && !HIDE_FIELD_IN_BLOCK.test(lower)) {
|
|
130
|
+
return "remove";
|
|
131
|
+
}
|
|
132
|
+
// Use earliest-verb-position to resolve ambiguity (e.g., "add a delete button" → "add")
|
|
133
|
+
const verbPatterns = [
|
|
134
|
+
{ action: "remove", pattern: /\b(remove|delete)\b/ },
|
|
135
|
+
{ action: "move", pattern: /\b(move|reorder|re-arrange|rearrange)\b/ },
|
|
136
|
+
{ action: "add", pattern: /\b(add|insert|create|include|put)\b/ },
|
|
137
|
+
{ action: "update", pattern: /\b(update|change|edit|set|rewrit\w*|reword\w*|rephras\w*|replace|improve|shorten|reduc\w*|trim|cut|polish\w*|refin\w*|refresh\w*|tighten\w*|clarif\w*|rename)\b/ },
|
|
138
|
+
];
|
|
139
|
+
let earliest = null;
|
|
140
|
+
for (const { action, pattern } of verbPatterns) {
|
|
141
|
+
const match = pattern.exec(lower);
|
|
142
|
+
if (match && (earliest === null || match.index < earliest.index)) {
|
|
143
|
+
earliest = { action, index: match.index };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (earliest)
|
|
147
|
+
return earliest.action;
|
|
148
|
+
if (/\bad\b/.test(lower) && inferBlockTypeFromText(lower))
|
|
149
|
+
return "add";
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function inferPatchFromMessage(args) {
|
|
153
|
+
const { message, action, targetBlock, activeEditablePath } = args;
|
|
154
|
+
if (action !== "update")
|
|
155
|
+
return undefined;
|
|
156
|
+
const heroImagePositionPatch = inferHeroImagePositionPatch(message, targetBlock);
|
|
157
|
+
if (heroImagePositionPatch)
|
|
158
|
+
return heroImagePositionPatch;
|
|
159
|
+
const directPatch = inferSimpleFieldPatchFromMessage(message);
|
|
160
|
+
if (directPatch && Object.keys(directPatch).length > 0)
|
|
161
|
+
return directPatch;
|
|
162
|
+
const quoted = quotedText(message);
|
|
163
|
+
if (!quoted)
|
|
164
|
+
return undefined;
|
|
165
|
+
if (activeEditablePath && /^[a-zA-Z0-9_]+$/.test(activeEditablePath)) {
|
|
166
|
+
return { [activeEditablePath]: quoted };
|
|
167
|
+
}
|
|
168
|
+
const block = targetBlock;
|
|
169
|
+
if (!block)
|
|
170
|
+
return undefined;
|
|
171
|
+
const allowedKeys = Object.keys(block.props);
|
|
172
|
+
if (allowedKeys.length === 0)
|
|
173
|
+
return undefined;
|
|
174
|
+
const hintedKey = inferFieldHintFromMessage(message, allowedKeys);
|
|
175
|
+
if (!hintedKey)
|
|
176
|
+
return undefined;
|
|
177
|
+
return { [hintedKey]: quoted };
|
|
178
|
+
}
|
|
179
|
+
function inferHeroImagePositionPatch(message, targetBlock) {
|
|
180
|
+
if (!targetBlock || targetBlock.type !== "Hero")
|
|
181
|
+
return null;
|
|
182
|
+
const lower = message.toLowerCase();
|
|
183
|
+
const asksSwapImageAndText = (/\bswap\b/.test(lower) || /\bflip\b/.test(lower) || /\breverse\b/.test(lower)) &&
|
|
184
|
+
/\b(image|photo|picture)\b/.test(lower) &&
|
|
185
|
+
/\b(text|copy|content)\b/.test(lower);
|
|
186
|
+
if (asksSwapImageAndText) {
|
|
187
|
+
const current = String(targetBlock.props.imagePosition ?? "right");
|
|
188
|
+
return { imagePosition: current === "left" ? "right" : "left" };
|
|
189
|
+
}
|
|
190
|
+
if (/\b(image|photo|picture)\b[\s\w-]*\b(?:on|to)\s+the\s+left\b/.test(lower) ||
|
|
191
|
+
/\b(?:left|left-side)\s+(?:image|photo|picture)\b/.test(lower) ||
|
|
192
|
+
/\b(?:image|photo|picture)\s+(?:left|left-side)\b/.test(lower)) {
|
|
193
|
+
return { imagePosition: "left" };
|
|
194
|
+
}
|
|
195
|
+
if (/\b(image|photo|picture)\b[\s\w-]*\b(?:on|to)\s+the\s+right\b/.test(lower) ||
|
|
196
|
+
/\b(?:right|right-side)\s+(?:image|photo|picture)\b/.test(lower) ||
|
|
197
|
+
/\b(?:image|photo|picture)\s+(?:right|right-side)\b/.test(lower)) {
|
|
198
|
+
return { imagePosition: "right" };
|
|
199
|
+
}
|
|
200
|
+
if (/\b(text|copy|content)\b[\s\w-]*\b(?:on|to)\s+the\s+left\b/.test(lower) ||
|
|
201
|
+
/\b(?:left|left-side)\s+(?:text|copy|content)\b/.test(lower) ||
|
|
202
|
+
/\b(?:text|copy|content)\s+(?:left|left-side)\b/.test(lower)) {
|
|
203
|
+
return { imagePosition: "right" };
|
|
204
|
+
}
|
|
205
|
+
if (/\b(text|copy|content)\b[\s\w-]*\b(?:on|to)\s+the\s+right\b/.test(lower) ||
|
|
206
|
+
/\b(?:right|right-side)\s+(?:text|copy|content)\b/.test(lower) ||
|
|
207
|
+
/\b(?:text|copy|content)\s+(?:right|right-side)\b/.test(lower)) {
|
|
208
|
+
return { imagePosition: "left" };
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
export function inferDeterministicIntent(args) {
|
|
213
|
+
const raw = stripSiteContextEnvelope(args.message).trim();
|
|
214
|
+
if (!raw)
|
|
215
|
+
return null;
|
|
216
|
+
let action = inferActionFromMessage(raw);
|
|
217
|
+
if (!action)
|
|
218
|
+
return null;
|
|
219
|
+
// "remove all except hero" / "delete everything but CTA" — handle deterministically for remove
|
|
220
|
+
if (action === "remove" && EXCEPT_PATTERN.test(raw)) {
|
|
221
|
+
// If message scopes the remove to a specific container block (e.g. "from slides in Carousel"),
|
|
222
|
+
// this is an intra-block item operation — defer to LLM.
|
|
223
|
+
if (isContainerScopedRemove(raw))
|
|
224
|
+
return null;
|
|
225
|
+
let keepType = inferBlockTypeFromText(raw);
|
|
226
|
+
// "this one" / "this block" / "the selected" → resolve from activeBlockId
|
|
227
|
+
if (!keepType && args.activeBlockId && THIS_ONE_PATTERN.test(raw)) {
|
|
228
|
+
const activeBlock = args.currentPage.blocks.find((b) => b.id === args.activeBlockId);
|
|
229
|
+
if (activeBlock)
|
|
230
|
+
keepType = activeBlock.type;
|
|
231
|
+
}
|
|
232
|
+
if (keepType && args.currentPage.blocks.some((b) => b.type === keepType)) {
|
|
233
|
+
return {
|
|
234
|
+
action: "remove",
|
|
235
|
+
target_block_type: keepType,
|
|
236
|
+
summary: `Removed all blocks except ${keepType}.`
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
// "add all except ..." — needs LLM
|
|
242
|
+
if (action === "add" && EXCEPT_PATTERN.test(raw)) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
// In focused inline-edit mode, "add image/photo" should update the selected image field,
|
|
246
|
+
// not add a new block.
|
|
247
|
+
if (action === "add" &&
|
|
248
|
+
typeof args.activeEditablePath === "string" &&
|
|
249
|
+
args.activeEditablePath.trim() === "imageUrl" &&
|
|
250
|
+
/\b(image|photo|picture)\b/i.test(raw)) {
|
|
251
|
+
action = "update";
|
|
252
|
+
}
|
|
253
|
+
const refs = resolveReferencesFromMessage({
|
|
254
|
+
message: raw,
|
|
255
|
+
currentPage: args.currentPage,
|
|
256
|
+
activeBlockId: args.activeBlockId
|
|
257
|
+
});
|
|
258
|
+
let targetBlock = refs.target
|
|
259
|
+
? args.currentPage.blocks.find((block) => block.id === refs.target?.id) ?? null
|
|
260
|
+
: null;
|
|
261
|
+
if (!targetBlock && args.activeBlockId) {
|
|
262
|
+
targetBlock = args.currentPage.blocks.find((block) => block.id === args.activeBlockId) ?? null;
|
|
263
|
+
}
|
|
264
|
+
const inferred = { action };
|
|
265
|
+
if (targetBlock) {
|
|
266
|
+
inferred.target_block_ref = targetBlock.id;
|
|
267
|
+
inferred.target_block_type = targetBlock.type;
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
// For an add, the trailing "below/after the <Type>" names the placement
|
|
271
|
+
// anchor — not a target to act on — so scan the anchor-stripped text. Otherwise
|
|
272
|
+
// target_block_type latches onto the anchor's type and resurfaces as the block
|
|
273
|
+
// to create via the compile fallback chain (#18).
|
|
274
|
+
const scanText = action === "add" ? stripPositionalAnchorPhrase(raw) : raw;
|
|
275
|
+
const typeFromMessage = inferBlockTypeFromText(scanText);
|
|
276
|
+
if (typeFromMessage)
|
|
277
|
+
inferred.target_block_type = typeFromMessage;
|
|
278
|
+
if (typeFromMessage) {
|
|
279
|
+
const byType = args.currentPage.blocks.filter((block) => block.type === typeFromMessage);
|
|
280
|
+
if (byType.length === 1) {
|
|
281
|
+
targetBlock = byType[0] ?? null;
|
|
282
|
+
if (targetBlock)
|
|
283
|
+
inferred.target_block_ref = targetBlock.id;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (action === "add") {
|
|
288
|
+
// Infer the block to ADD from the message with any trailing positional anchor
|
|
289
|
+
// removed ("add a gallery after the testimonials" → "add a gallery"), so the
|
|
290
|
+
// anchor's own type isn't picked up as the new block (#18). Fall back to the
|
|
291
|
+
// ACTIVELY-SELECTED block's type only — never to a targetBlock resolved from a
|
|
292
|
+
// free-text scan, which above (lines ~303-312) latches onto the anchor's type.
|
|
293
|
+
const addText = stripPositionalAnchorPhrase(raw);
|
|
294
|
+
const selectedType = targetBlock && targetBlock.id === args.activeBlockId ? targetBlock.type : undefined;
|
|
295
|
+
// Item words that only exist INSIDE a container map to that container's type,
|
|
296
|
+
// so "add a tab called Pricing" resolves to Tabs — not CardGrid (via the
|
|
297
|
+
// incidental "Pricing") or FAQAccordion (via a "FAQ" label) (#22). Checked
|
|
298
|
+
// first so the generic keyword scan can't latch onto label noise.
|
|
299
|
+
const itemContainerType = inferItemContainerType(addText);
|
|
300
|
+
const inferredAddType = itemContainerType ??
|
|
301
|
+
inferAddedBlockTypeFromMessage(addText) ??
|
|
302
|
+
inferBlockTypeFromText(addText) ??
|
|
303
|
+
selectedType;
|
|
304
|
+
if (inferredAddType)
|
|
305
|
+
inferred.new_block_type = inferredAddType;
|
|
306
|
+
}
|
|
307
|
+
if (action === "move" || action === "add") {
|
|
308
|
+
const lower = raw.toLowerCase();
|
|
309
|
+
if (/\b(top|first|start|beginning)\b/.test(lower))
|
|
310
|
+
inferred.position = "top";
|
|
311
|
+
else if (/\b(bottom|last|end)\b/.test(lower))
|
|
312
|
+
inferred.position = "bottom";
|
|
313
|
+
else if (/\b(before|above)\b/.test(lower))
|
|
314
|
+
inferred.position = "before";
|
|
315
|
+
else if (/\b(after|below|under)\b/.test(lower))
|
|
316
|
+
inferred.position = "after";
|
|
317
|
+
if (refs.anchor?.id)
|
|
318
|
+
inferred.anchor_block_ref = refs.anchor.id;
|
|
319
|
+
}
|
|
320
|
+
const patch = inferPatchFromMessage({
|
|
321
|
+
message: raw,
|
|
322
|
+
action,
|
|
323
|
+
targetBlock: targetBlock ?? undefined,
|
|
324
|
+
activeEditablePath: args.activeEditablePath
|
|
325
|
+
});
|
|
326
|
+
if (patch && Object.keys(patch).length > 0)
|
|
327
|
+
inferred.patch = patch;
|
|
328
|
+
return inferred;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Returns true when the message mentions a specific block type or block-synonymous
|
|
332
|
+
* term, indicating the user is talking about a block — not about the page itself.
|
|
333
|
+
* Used to guard page-level operations from misclassifying block-level requests.
|
|
334
|
+
*
|
|
335
|
+
* Strips noise that causes false positives:
|
|
336
|
+
* - Text after "this/the page" (page name refs like "move this page before Pricing")
|
|
337
|
+
* - Route slugs ("/pricing", "/about-us") — these are page refs, not block types
|
|
338
|
+
*/
|
|
339
|
+
function messageRefersToBlock(message, activeBlockId) {
|
|
340
|
+
// When the message explicitly says "this/the page", "this" refers to the page, not the selected block
|
|
341
|
+
if (activeBlockId && /\b(this|the|current)\s+page\b/i.test(message))
|
|
342
|
+
return false;
|
|
343
|
+
if (activeBlockId)
|
|
344
|
+
return true;
|
|
345
|
+
// Strip text after "this/the/current page" (page name references)
|
|
346
|
+
// and strip route slugs (/pricing, /about) that could false-match block keywords
|
|
347
|
+
const cleaned = message
|
|
348
|
+
.replace(/\b(?:this|the|current)\s+page\b.*/i, "")
|
|
349
|
+
.replace(/\/[a-z0-9_-]+/gi, "");
|
|
350
|
+
if (inferBlockTypeFromText(cleaned))
|
|
351
|
+
return true;
|
|
352
|
+
// "the/this section", "a component", "the block" — block-synonymous terms with article
|
|
353
|
+
if (/\b(?:the|this|a|that)\s+(?:section|component|block|element|widget)\b/i.test(cleaned))
|
|
354
|
+
return true;
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Detects "move (this) page before/after X" — page nav move requests.
|
|
359
|
+
* Matches both route-based ("move page after /pricing") and natural language
|
|
360
|
+
* ("move this page before About us") anchor references.
|
|
361
|
+
* Requires "move" directly adjacent to "this/the/current page" to avoid matching
|
|
362
|
+
* block positioning phrases like "move CTA to the top of the page".
|
|
363
|
+
*/
|
|
364
|
+
function isNavMoveRequest(message) {
|
|
365
|
+
const lower = message.toLowerCase();
|
|
366
|
+
const movesThisPage = /\bmove\s+(?:this|the|current)\s+page\b/.test(lower);
|
|
367
|
+
const hasPlacement = /\b(before|after|above|below|first|last|top|bottom|start|end|beginning)\b/.test(lower);
|
|
368
|
+
return movesThisPage && hasPlacement;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Resolves a natural language page name (e.g. "About us") to a slug by matching
|
|
372
|
+
* against page titles in the session draft. Returns undefined if no match.
|
|
373
|
+
*/
|
|
374
|
+
function resolvePageSlugByTitle(sessionDraft, name) {
|
|
375
|
+
const lower = name.toLowerCase().trim();
|
|
376
|
+
if (!lower)
|
|
377
|
+
return undefined;
|
|
378
|
+
for (const [slug, page] of sessionDraft) {
|
|
379
|
+
if (page.title.toLowerCase() === lower)
|
|
380
|
+
return slug;
|
|
381
|
+
}
|
|
382
|
+
// Fuzzy: check if name is contained in title or vice versa
|
|
383
|
+
for (const [slug, page] of sessionDraft) {
|
|
384
|
+
const titleLower = page.title.toLowerCase();
|
|
385
|
+
if (titleLower.includes(lower) || lower.includes(titleLower))
|
|
386
|
+
return slug;
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
// Conjunctions that separate independent clauses (", and …", "… then …", "; …").
|
|
391
|
+
// Bare commas are intentionally excluded so a value/list comma doesn't over-split.
|
|
392
|
+
const CLAUSE_CONJUNCTION = /\s*(?:;|,?\s+(?:and|then|also|plus)\s+)/i;
|
|
393
|
+
// A broad set of edit verbs, INCLUDING natural-language ones the verb-position
|
|
394
|
+
// classifier ignores ("make", "put", "take out", "rename", "reflect").
|
|
395
|
+
const CLAUSE_ACTION_VERB = /\b(?:add|insert|create|include|put|remove|delete|clear|take\s+out|get\s+rid|move|reorder|rearrange|swap|update|change|edit|set|replace|rewrite|reword|rephrase|make|rename|shorten|tighten|reflect|turn|hide|show|sort|soften|expand|highlight)\b/i;
|
|
396
|
+
/**
|
|
397
|
+
* True when the message carries more than one independent edit intent ("add a
|
|
398
|
+
* testimonials section … and make the CTA …", "delete the 2nd and 3rd cards … and
|
|
399
|
+
* rename the first"). The deterministic path models a SINGLE simple intent, so it
|
|
400
|
+
* would handle only the first clause and silently drop the rest — these must defer to
|
|
401
|
+
* the LLM, which plans every clause. A single intent with an incidental conjunction
|
|
402
|
+
* inside a quoted value or an adjective list ("'Health and Wellness'", "bigger and
|
|
403
|
+
* bolder") has only ONE action-bearing clause and stays deterministic. (#32)
|
|
404
|
+
*/
|
|
405
|
+
/** Blank out quoted values so a conjunction/verb inside a replacement string
|
|
406
|
+
* ("… to 'Cut costs and move faster'") can't be read as a clause boundary. */
|
|
407
|
+
function stripQuotedValues(raw) {
|
|
408
|
+
return raw
|
|
409
|
+
.replace(/"[^"]*"/g, " ")
|
|
410
|
+
.replace(/'[^']*'/g, " ")
|
|
411
|
+
.replace(/“[^”]*”/g, " ")
|
|
412
|
+
.replace(/‘[^’]*’/g, " ");
|
|
413
|
+
}
|
|
414
|
+
export function isMultiIntentRequest(raw) {
|
|
415
|
+
// Strip quoted values first: "change the heading to 'Cut costs and move faster'"
|
|
416
|
+
// is ONE intent, but the "and" inside the value would otherwise split it into two
|
|
417
|
+
// pseudo-clauses that each look action-bearing → false multi-intent deferral.
|
|
418
|
+
const clauses = stripQuotedValues(raw).split(CLAUSE_CONJUNCTION).filter((c) => c.trim().length > 0);
|
|
419
|
+
if (clauses.length < 2)
|
|
420
|
+
return false;
|
|
421
|
+
return clauses.filter((c) => CLAUSE_ACTION_VERB.test(c)).length >= 2;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Returns true only when UI context makes the intent completely unambiguous,
|
|
425
|
+
* so the deterministic planner can handle it without an LLM call.
|
|
426
|
+
*/
|
|
427
|
+
export function isHighConfidenceDeterministicCase(args) {
|
|
428
|
+
const raw = stripSiteContextEnvelope(args.message).trim();
|
|
429
|
+
if (!raw)
|
|
430
|
+
return false;
|
|
431
|
+
// Case 1: Inline edit — user clicked a field, typed a quoted value
|
|
432
|
+
if (args.activeBlockId && args.activeEditablePath && quotedText(raw))
|
|
433
|
+
return true;
|
|
434
|
+
// Case 2: Inline list append — "add another item" with block selected
|
|
435
|
+
if (args.activeBlockId) {
|
|
436
|
+
const block = args.currentPage.blocks.find(b => b.id === args.activeBlockId);
|
|
437
|
+
if (block && /\b(add|insert)\b/i.test(raw) && /\b(another|more|new)\b/i.test(raw)) {
|
|
438
|
+
if (buildListAppendPatch(block, raw))
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Case 3: Simple add/remove with clear block type — no LLM needed
|
|
443
|
+
// Bail out for compound requests ("remove X and add Y") — these need the LLM
|
|
444
|
+
const hasCompoundAction = /\b(remove|delete|clear)\b.+\band\b.+\b(add|insert|create)\b/i.test(raw)
|
|
445
|
+
|| /\b(add|insert|create)\b.+\band\b.+\b(remove|delete|clear)\b/i.test(raw);
|
|
446
|
+
if (hasCompoundAction)
|
|
447
|
+
return false;
|
|
448
|
+
// Bail out for multi-intent requests the single-intent deterministic path can't model
|
|
449
|
+
// ("add a testimonials section ... and make the CTA ...") — it would handle only the
|
|
450
|
+
// first clause and silently drop the rest. The compound decomposer / LLM take over. (#32)
|
|
451
|
+
if (isMultiIntentRequest(raw))
|
|
452
|
+
return false;
|
|
453
|
+
const hasExceptionModifier = EXCEPT_PATTERN.test(raw);
|
|
454
|
+
if (hasExceptionModifier) {
|
|
455
|
+
// "remove all except X" can be handled deterministically when X resolves to a block type on the page
|
|
456
|
+
const action = inferActionFromMessage(raw);
|
|
457
|
+
if (action === "remove") {
|
|
458
|
+
if (isContainerScopedRemove(raw))
|
|
459
|
+
return false; // intra-block item op — defer to LLM
|
|
460
|
+
let keepType = inferBlockTypeFromText(raw);
|
|
461
|
+
if (!keepType && args.activeBlockId && THIS_ONE_PATTERN.test(raw)) {
|
|
462
|
+
const activeBlock = args.currentPage.blocks.find((b) => b.id === args.activeBlockId);
|
|
463
|
+
if (activeBlock)
|
|
464
|
+
keepType = activeBlock.type;
|
|
465
|
+
}
|
|
466
|
+
if (keepType && args.currentPage.blocks.some((b) => b.type === keepType))
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
const action = inferActionFromMessage(raw);
|
|
472
|
+
// Cross-page scope ("update the CTA on all pages", "change X site-wide") can't be
|
|
473
|
+
// honored by the single-page deterministic path — it would silently narrow the edit
|
|
474
|
+
// to the current page only. Defer to the LLM, which can fan out across pages (ops
|
|
475
|
+
// carry pageSlug) or clarify. Create-page requests are exempt — they legitimately
|
|
476
|
+
// target one new page, not the whole site (issue #29).
|
|
477
|
+
if (hasCrossPageScope(raw) && !parseCreatePageRequest(raw))
|
|
478
|
+
return false;
|
|
479
|
+
// Case: create_page — "create a new /about page", "create a new test page"
|
|
480
|
+
// Defer to LLM when user wants content generation (topic + block types enumerated)
|
|
481
|
+
if (action === "add" && parseCreatePageRequest(raw) && !requestsContentGeneration(raw))
|
|
482
|
+
return true;
|
|
483
|
+
// "add X to each/every card" = bulk item update — needs LLM for content generation
|
|
484
|
+
if (action === "add" && /\b(each|every)\b/i.test(raw))
|
|
485
|
+
return false;
|
|
486
|
+
// Counted multi-block add without enough named types → needs LLM for content generation
|
|
487
|
+
if (action === "add" && isBatchAddRequest(raw) && extractMentionedBlockTypes(raw).length < 2)
|
|
488
|
+
return false;
|
|
489
|
+
// Case: page rename — "rename page to Our community", "rename this page to /plans"
|
|
490
|
+
if (action === "update" && isPageRouteRenameRequest(raw))
|
|
491
|
+
return true;
|
|
492
|
+
// Case: page nav move — "move this page before About us", "move page after /pricing"
|
|
493
|
+
// Guard: if message mentions a block type, it's a block move, not a nav move
|
|
494
|
+
if (action === "move" && isNavMoveRequest(raw) && !messageRefersToBlock(raw, args.activeBlockId))
|
|
495
|
+
return true;
|
|
496
|
+
// Case: page-level delete — "delete this page", "remove the page"
|
|
497
|
+
if (action === "remove" && /\b(delete|remove)\b.*\bpage\b/i.test(raw) && !messageRefersToBlock(raw, args.activeBlockId))
|
|
498
|
+
return true;
|
|
499
|
+
// Multiple distinct removal targets ("remove the testimonials, the CTA, and both two-column sections")
|
|
500
|
+
// — the deterministic path infers only the FIRST block type and emits a single remove_block,
|
|
501
|
+
// silently dropping the rest. Defer to the LLM so every requested target is handled (issue #17B).
|
|
502
|
+
if (action === "remove" && extractMentionedBlockTypes(raw).length >= 2)
|
|
503
|
+
return false;
|
|
504
|
+
const removeType = inferBlockTypeFromText(raw);
|
|
505
|
+
if (action === "remove" && removeType) {
|
|
506
|
+
if (args.currentPage.blocks.filter((b) => b.type === removeType).length > 1)
|
|
507
|
+
return false;
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
if (action === "remove" && args.activeBlockId && /\b(this|selected|it)\b/i.test(raw))
|
|
511
|
+
return true;
|
|
512
|
+
// "add a hero image of X" when Hero already exists = image update, not block add — defer to LLM
|
|
513
|
+
const addBlockType = action === "add" ? inferBlockTypeFromText(raw) : null;
|
|
514
|
+
if (action === "add" && addBlockType && /\b(image|photo|picture|icon)\b/i.test(raw) && args.currentPage.blocks.some(b => b.type === addBlockType))
|
|
515
|
+
return false;
|
|
516
|
+
// "add icons to the stats / features" — `icon` is a per-item FIELD, never a
|
|
517
|
+
// block or a list item, and "stats"/"features" don't resolve to a block type,
|
|
518
|
+
// so the guard above misses and the add-item path inserts a junk placeholder
|
|
519
|
+
// row ("100+ / New metric") instead of icons. Any "add … icon …" request is a
|
|
520
|
+
// field edit, so defer to the LLM (it sets the icon field via update_props).
|
|
521
|
+
if (action === "add" && /\bicons?\b/i.test(raw))
|
|
522
|
+
return false;
|
|
523
|
+
// "add CTA directing to recipes" = needs LLM for content-aware props
|
|
524
|
+
if (action === "add" && inferBlockTypeFromText(raw) && hasContentDirective(raw))
|
|
525
|
+
return false;
|
|
526
|
+
// Defer to LLM when topic + multiple block types are enumerated (needs content generation)
|
|
527
|
+
if (action === "add" && inferBlockTypeFromText(raw) && !requestsContentGeneration(raw))
|
|
528
|
+
return true;
|
|
529
|
+
// Compound multi-block update ("change the hero heading to 'X' and the CTA button
|
|
530
|
+
// to 'Y'") — the deterministic update path infers only the FIRST block type and
|
|
531
|
+
// emits a single update_props, silently dropping the other clause. When the message
|
|
532
|
+
// names ≥2 distinct block types joined by a conjunction (counted OUTSIDE quoted
|
|
533
|
+
// values so block words inside the replacement text don't inflate the count), defer
|
|
534
|
+
// to the LLM so every targeted block is updated (issue #29).
|
|
535
|
+
if (action === "update") {
|
|
536
|
+
const outsideQuotes = stripQuotedValues(raw);
|
|
537
|
+
if (/\b(?:and|plus)\b|&|,/.test(outsideQuotes) && extractMentionedBlockTypes(outsideQuotes).length >= 2) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Case 4: Simple update with clear block type reference and quoted value
|
|
542
|
+
if (action === "update" && inferBlockTypeFromText(raw) && quotedText(raw)) {
|
|
543
|
+
if (hasQuotedConstraintOnlyText(raw))
|
|
544
|
+
return false;
|
|
545
|
+
if (isRewriteRequest(raw) && !hasQuotedReplacementDirective(raw))
|
|
546
|
+
return false;
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Splits a compound message ("remove the hero and add a CTA") into sub-parts
|
|
553
|
+
* at conjunction boundaries, but only when the parts contain verbs from
|
|
554
|
+
* different action categories (add vs remove, etc.).
|
|
555
|
+
*
|
|
556
|
+
* Returns null when the message is not a decomposable compound request.
|
|
557
|
+
*/
|
|
558
|
+
function splitCompoundMessage(raw) {
|
|
559
|
+
// Bail when 3+ distinct action verbs are present — too complex for 2-way split
|
|
560
|
+
const lower = raw.toLowerCase();
|
|
561
|
+
const verbCategories = [
|
|
562
|
+
/\b(?:add|insert|create|include)\b/,
|
|
563
|
+
/\b(?:remove|delete|clear)\b/,
|
|
564
|
+
/\b(?:move|reorder|rearrange)\b/,
|
|
565
|
+
/\b(?:update|change|edit|set|replace)\b/,
|
|
566
|
+
];
|
|
567
|
+
const matchedCategories = verbCategories.filter((p) => p.test(lower)).length;
|
|
568
|
+
if (matchedCategories >= 3)
|
|
569
|
+
return null;
|
|
570
|
+
// Only split when two different action-category verbs are connected by a conjunction.
|
|
571
|
+
// Pattern: <verb-group-A> ... <and|then|,> ... <verb-group-B>
|
|
572
|
+
const addVerbs = String.raw `(?:add|insert|create|include)`;
|
|
573
|
+
const removeVerbs = String.raw `(?:remove|delete|clear)`;
|
|
574
|
+
const conjunction = String.raw `(?:\s+and\s+|\s+then\s+|\s*,\s*(?:and\s+)?(?:then\s+)?)`;
|
|
575
|
+
// Try add...conjunction...remove
|
|
576
|
+
const arPattern = new RegExp(String.raw `(.*\b${addVerbs}\b[^,]*?)${conjunction}(\b${removeVerbs}\b.*)`, "i");
|
|
577
|
+
const arMatch = raw.match(arPattern);
|
|
578
|
+
if (arMatch?.[1] && arMatch?.[2])
|
|
579
|
+
return [arMatch[1].trim(), arMatch[2].trim()];
|
|
580
|
+
// Try remove...conjunction...add
|
|
581
|
+
const raPattern = new RegExp(String.raw `(.*\b${removeVerbs}\b[^,]*?)${conjunction}(\b${addVerbs}\b.*)`, "i");
|
|
582
|
+
const raMatch = raw.match(raPattern);
|
|
583
|
+
if (raMatch?.[1] && raMatch?.[2])
|
|
584
|
+
return [raMatch[1].trim(), raMatch[2].trim()];
|
|
585
|
+
// Try move...conjunction...add/remove
|
|
586
|
+
const moveVerbs = String.raw `(?:move|reorder|rearrange)`;
|
|
587
|
+
const updateVerbs = String.raw `(?:update|change|edit|set|replace)`;
|
|
588
|
+
const pairs = [
|
|
589
|
+
[moveVerbs, addVerbs],
|
|
590
|
+
[moveVerbs, removeVerbs],
|
|
591
|
+
[addVerbs, updateVerbs],
|
|
592
|
+
[removeVerbs, updateVerbs],
|
|
593
|
+
[updateVerbs, addVerbs],
|
|
594
|
+
[updateVerbs, removeVerbs],
|
|
595
|
+
];
|
|
596
|
+
for (const [a, b] of pairs) {
|
|
597
|
+
const pattern = new RegExp(String.raw `(.*\b${a}\b[^,]*?)${conjunction}(\b${b}\b.*)`, "i");
|
|
598
|
+
const match = raw.match(pattern);
|
|
599
|
+
if (match?.[1] && match?.[2])
|
|
600
|
+
return [match[1].trim(), match[2].trim()];
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Attempts to handle a compound message ("remove the hero and add a CTA")
|
|
606
|
+
* by decomposing it into sub-intents and running each through the
|
|
607
|
+
* deterministic planner. Returns null if any sub-intent can't be handled
|
|
608
|
+
* deterministically.
|
|
609
|
+
*/
|
|
610
|
+
export function tryCompoundDeterministicPlan(args) {
|
|
611
|
+
const raw = stripSiteContextEnvelope(args.message).trim();
|
|
612
|
+
if (!raw)
|
|
613
|
+
return null;
|
|
614
|
+
const parts = splitCompoundMessage(raw);
|
|
615
|
+
if (!parts)
|
|
616
|
+
return null;
|
|
617
|
+
const [firstMsg, secondMsg] = parts;
|
|
618
|
+
// Check both parts individually for high-confidence
|
|
619
|
+
for (const msg of [firstMsg, secondMsg]) {
|
|
620
|
+
if (!isHighConfidenceDeterministicCase({
|
|
621
|
+
message: msg,
|
|
622
|
+
currentPage: args.currentPage,
|
|
623
|
+
activeBlockId: args.activeBlockId,
|
|
624
|
+
activeEditablePath: args.activeEditablePath
|
|
625
|
+
}))
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
// Build intent + plan for first part
|
|
629
|
+
const firstIntent = inferDeterministicIntent({
|
|
630
|
+
message: firstMsg,
|
|
631
|
+
currentPage: args.currentPage,
|
|
632
|
+
activeBlockId: args.activeBlockId,
|
|
633
|
+
activeEditablePath: args.activeEditablePath
|
|
634
|
+
});
|
|
635
|
+
if (!firstIntent)
|
|
636
|
+
return null;
|
|
637
|
+
const firstPlan = compileDeterministicPlan({
|
|
638
|
+
session: args.session,
|
|
639
|
+
intent: firstIntent,
|
|
640
|
+
message: firstMsg,
|
|
641
|
+
slug: args.slug,
|
|
642
|
+
currentPage: args.currentPage,
|
|
643
|
+
activeBlockId: args.activeBlockId,
|
|
644
|
+
activeEditablePath: args.activeEditablePath
|
|
645
|
+
});
|
|
646
|
+
if (!firstPlan || firstPlan.intent !== "edit_plan" || firstPlan.ops.length === 0)
|
|
647
|
+
return null;
|
|
648
|
+
// Apply first plan's ops to a cloned page so second plan sees updated state
|
|
649
|
+
// (e.g., removed blocks are no longer visible, added blocks can be referenced)
|
|
650
|
+
let updatedPage = args.currentPage;
|
|
651
|
+
for (const op of firstPlan.ops) {
|
|
652
|
+
if (op.op === "remove_block") {
|
|
653
|
+
updatedPage = { ...updatedPage, blocks: updatedPage.blocks.filter((b) => b.id !== op.blockId) };
|
|
654
|
+
}
|
|
655
|
+
else if (op.op === "add_block") {
|
|
656
|
+
updatedPage = { ...updatedPage, blocks: [...updatedPage.blocks, op.block] };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Build intent + plan for second part
|
|
660
|
+
const secondIntent = inferDeterministicIntent({
|
|
661
|
+
message: secondMsg,
|
|
662
|
+
currentPage: updatedPage,
|
|
663
|
+
activeBlockId: args.activeBlockId,
|
|
664
|
+
activeEditablePath: args.activeEditablePath
|
|
665
|
+
});
|
|
666
|
+
if (!secondIntent)
|
|
667
|
+
return null;
|
|
668
|
+
const secondPlan = compileDeterministicPlan({
|
|
669
|
+
session: args.session,
|
|
670
|
+
intent: secondIntent,
|
|
671
|
+
message: secondMsg,
|
|
672
|
+
slug: args.slug,
|
|
673
|
+
currentPage: updatedPage,
|
|
674
|
+
activeBlockId: args.activeBlockId,
|
|
675
|
+
activeEditablePath: args.activeEditablePath
|
|
676
|
+
});
|
|
677
|
+
if (!secondPlan || secondPlan.intent !== "edit_plan" || secondPlan.ops.length === 0)
|
|
678
|
+
return null;
|
|
679
|
+
// Merge: removes first (so IDs are valid), then adds
|
|
680
|
+
const removeOps = [...firstPlan.ops, ...secondPlan.ops].filter(op => op.op === "remove_block" || op.op === "remove_page");
|
|
681
|
+
const otherOps = [...firstPlan.ops, ...secondPlan.ops].filter(op => op.op !== "remove_block" && op.op !== "remove_page");
|
|
682
|
+
return {
|
|
683
|
+
intent: "edit_plan",
|
|
684
|
+
summary_for_user: [firstPlan.summary_for_user, secondPlan.summary_for_user].join(" "),
|
|
685
|
+
change_log: [...firstPlan.change_log, ...secondPlan.change_log],
|
|
686
|
+
ops: [...removeOps, ...otherOps]
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// Header / site-config shortcut
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
const SMART_QUOTES_RE = /[‘’“”]/g;
|
|
693
|
+
function normalizeQuotes(value) {
|
|
694
|
+
return value.replace(SMART_QUOTES_RE, (ch) => (ch === "‘" || ch === "’" ? "'" : '"'));
|
|
695
|
+
}
|
|
696
|
+
function stripWrappingQuotes(value) {
|
|
697
|
+
const trimmed = value.trim().replace(/[.!?;:]+$/, "").trim();
|
|
698
|
+
const m = trimmed.match(/^["'](.+)["']$/);
|
|
699
|
+
return (m ? m[1] : trimmed).trim();
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Matches high-confidence requests targeting the SiteHeader / siteConfig.
|
|
703
|
+
* Returns a one-op `update_site_config` plan or null if no clear match.
|
|
704
|
+
*/
|
|
705
|
+
function tryHeaderConfigPlan(args) {
|
|
706
|
+
const { session, message, assumptions } = args;
|
|
707
|
+
const lower = message.toLowerCase();
|
|
708
|
+
const draft = getSessionDraft(session);
|
|
709
|
+
const config = getSiteConfig(session);
|
|
710
|
+
// ── Logo ──────────────────────────────────────────────────────────────
|
|
711
|
+
const messageNorm = normalizeQuotes(message);
|
|
712
|
+
const mentionsLogo = /\b(logo)\b/.test(lower);
|
|
713
|
+
if (mentionsLogo) {
|
|
714
|
+
// "use URL as the logo" / "set logo to URL" / "change the logo to URL"
|
|
715
|
+
const useAs = messageNorm.match(/\buse\s+["']?(\S+?)["']?\s+as\s+(?:the\s+)?(?:site\s+)?logo\b/i);
|
|
716
|
+
const setTo = messageNorm.match(/\b(?:change|set|update|make)\s+(?:the\s+)?(?:site\s+)?logo\s+(?:to|=)\s+(.+?)$/i);
|
|
717
|
+
const raw = useAs ? useAs[1] : setTo ? setTo[1] : null;
|
|
718
|
+
if (raw) {
|
|
719
|
+
const url = stripWrappingQuotes(raw);
|
|
720
|
+
if (/^(https?:\/\/|\/)/.test(url) && !/\s/.test(url)) {
|
|
721
|
+
return {
|
|
722
|
+
intent: "edit_plan",
|
|
723
|
+
summary_for_user: `Will update the **site logo**.`,
|
|
724
|
+
change_log: [...assumptions, `Set site logo to ${url}.`],
|
|
725
|
+
ops: [{ op: "update_site_config", patch: { logo: url } }]
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
// ── Site name ────────────────────────────────────────────────────────
|
|
731
|
+
const mentionsSiteName = /\bsite\s+name\b/.test(lower) ||
|
|
732
|
+
/\brename\s+the\s+site\b/.test(lower) ||
|
|
733
|
+
/\bchange\s+the\s+site\s+to\b/.test(lower);
|
|
734
|
+
if (mentionsSiteName) {
|
|
735
|
+
const fromSiteName = messageNorm.match(/\bsite\s+name\s+(?:to|=|:)\s+(.+?)$/i);
|
|
736
|
+
const renameSite = messageNorm.match(/\brename\s+the\s+site\s+to\s+(.+)$/i);
|
|
737
|
+
const changeSite = messageNorm.match(/\bchange\s+the\s+site\s+to\s+(.+)$/i);
|
|
738
|
+
const raw = (fromSiteName ? stripWrappingQuotes(fromSiteName[1]) : null)
|
|
739
|
+
?? (renameSite ? stripWrappingQuotes(renameSite[1]) : null)
|
|
740
|
+
?? (changeSite ? stripWrappingQuotes(changeSite[1]) : null);
|
|
741
|
+
if (raw && raw.length > 0 && raw.length <= 80) {
|
|
742
|
+
return {
|
|
743
|
+
intent: "edit_plan",
|
|
744
|
+
summary_for_user: `Will rename the site to **${raw}**.`,
|
|
745
|
+
change_log: [...assumptions, `Set site name to "${raw}".`],
|
|
746
|
+
ops: [{ op: "update_site_config", patch: { name: raw } }]
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// ── Nav label rename ─────────────────────────────────────────────────
|
|
751
|
+
// Patterns: "rename the X link to Y", "rename X in the nav to Y",
|
|
752
|
+
// "relabel the X tab to Y", "change the X menu item to Y"
|
|
753
|
+
const navTokens = "(?:link|tab|menu(?:\\s+item)?|nav(?:igation)?(?:\\s+item)?|item|entry)";
|
|
754
|
+
const navContextRe = new RegExp(`\\b(?:rename|relabel|change|update|set)\\s+(?:the\\s+)?["']?([^"']+?)["']?\\s+${navTokens}\\s+to\\s+(.+)$`, "i");
|
|
755
|
+
const inNavRe = new RegExp(`\\b(?:rename|relabel|change|update|set)\\s+(?:the\\s+)?["']?([^"']+?)["']?\\s+(?:in|on)\\s+(?:the\\s+)?(?:nav(?:igation)?|menu|header)\\s+to\\s+(.+)$`, "i");
|
|
756
|
+
const navMatch = messageNorm.match(navContextRe) ?? messageNorm.match(inNavRe);
|
|
757
|
+
if (navMatch) {
|
|
758
|
+
const fromLabelRaw = stripWrappingQuotes(navMatch[1]);
|
|
759
|
+
const toLabel = stripWrappingQuotes(navMatch[2]);
|
|
760
|
+
if (fromLabelRaw && toLabel && toLabel.length <= 80) {
|
|
761
|
+
const slug = resolveNavSlugByLabel(fromLabelRaw, config.navLabels ?? {}, draft);
|
|
762
|
+
if (slug) {
|
|
763
|
+
const mergedLabels = { ...(config.navLabels ?? {}), [slug]: toLabel };
|
|
764
|
+
return {
|
|
765
|
+
intent: "edit_plan",
|
|
766
|
+
summary_for_user: `Will rename the **${fromLabelRaw}** nav link to **${toLabel}**.`,
|
|
767
|
+
change_log: [...assumptions, `Renamed nav label ${slug} from "${fromLabelRaw}" to "${toLabel}".`],
|
|
768
|
+
ops: [{ op: "update_site_config", patch: { navLabels: mergedLabels } }]
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
function resolveNavSlugByLabel(label, navLabels, draft) {
|
|
776
|
+
const lower = label.toLowerCase().trim();
|
|
777
|
+
if (!lower)
|
|
778
|
+
return undefined;
|
|
779
|
+
// 1. Existing override matches exactly
|
|
780
|
+
for (const [slug, override] of Object.entries(navLabels)) {
|
|
781
|
+
if (typeof override === "string" && override.toLowerCase() === lower)
|
|
782
|
+
return slug;
|
|
783
|
+
}
|
|
784
|
+
// 2. Page title matches (uses the same fuzzy matcher as page rename)
|
|
785
|
+
const byTitle = resolvePageSlugByTitle(draft, label);
|
|
786
|
+
if (byTitle)
|
|
787
|
+
return byTitle;
|
|
788
|
+
// 3. Slug last segment matches (e.g. "about" → /about, "our team" → /our-team)
|
|
789
|
+
const seed = toSeedSlug(label);
|
|
790
|
+
if (seed) {
|
|
791
|
+
const candidate = `/${seed}`;
|
|
792
|
+
if (draft.has(candidate))
|
|
793
|
+
return candidate;
|
|
794
|
+
}
|
|
795
|
+
return undefined;
|
|
796
|
+
}
|
|
797
|
+
export function compileDeterministicPlan(args) {
|
|
798
|
+
const { session, intent, message, slug, currentPage, activeBlockId, activeEditablePath } = args;
|
|
799
|
+
const cleanMessage = stripSiteContextEnvelope(message);
|
|
800
|
+
const lowerMessage = message.toLowerCase();
|
|
801
|
+
// Header / site-config requests bypass intent inference and resolve straight
|
|
802
|
+
// to update_site_config. The SiteHeader is not a page block, so phrasing like
|
|
803
|
+
// "rename the About link" or "change the logo" must NOT fall into update_props.
|
|
804
|
+
const headerPlan = tryHeaderConfigPlan({ session, message: cleanMessage, assumptions: intent.assumption ? [intent.assumption] : [] });
|
|
805
|
+
if (headerPlan)
|
|
806
|
+
return headerPlan;
|
|
807
|
+
const routeMentions = extractRouteMentions(cleanMessage);
|
|
808
|
+
const assumptions = [];
|
|
809
|
+
if (intent.assumption)
|
|
810
|
+
assumptions.push(intent.assumption);
|
|
811
|
+
const hasStructuralVerb = /\b(move|reorder|re-arrange|rearrange|add|remove|delete|create|duplicate|rename)\b/.test(lowerMessage);
|
|
812
|
+
if (process.env.OPENAI_API_KEY &&
|
|
813
|
+
(intent.action === "update" || intent.action === "clarify") &&
|
|
814
|
+
!hasStructuralVerb &&
|
|
815
|
+
isRewriteRequest(message) &&
|
|
816
|
+
(!hasQuotedReplacementDirective(message) || hasQuotedConstraintOnlyText(message)))
|
|
817
|
+
return null;
|
|
818
|
+
const hasConditionalQualifier = /\bif\s+(required|needed|necessary)\b/.test(lowerMessage);
|
|
819
|
+
const asksSectionReorder = /\b(reorder|re-order|rearrange|re-organize|reorganize)\b/.test(lowerMessage) &&
|
|
820
|
+
/\b(section|sections|block|blocks|content|layout|flow|readability)\b/.test(lowerMessage);
|
|
821
|
+
const hasExplicitPlacementCue = /\b(top|bottom|first|last|before|after|above|below|under|between)\b/.test(lowerMessage);
|
|
822
|
+
const hasExplicitBlockMentionInMessage = /\bb_[a-z0-9_]+\b/.test(lowerMessage) ||
|
|
823
|
+
/\b(hero|feature grid|features|testimonials?|faq|cta|card grid|cards?|rich[\s-]?text)\b/.test(lowerMessage);
|
|
824
|
+
const selectedBlock = activeBlockId ? currentPage.blocks.find((b) => b.id === activeBlockId) ?? null : null;
|
|
825
|
+
const secondaryButtonMentioned = lowerMessage.includes("secondary cta") ||
|
|
826
|
+
lowerMessage.includes("secondary button") ||
|
|
827
|
+
lowerMessage.includes("second cta") ||
|
|
828
|
+
lowerMessage.includes("second button") ||
|
|
829
|
+
/sec\w*nd\w*ry\s+(cta|button)/.test(lowerMessage);
|
|
830
|
+
const asksSecondaryCtaAdd = secondaryButtonMentioned &&
|
|
831
|
+
(lowerMessage.includes("add") || lowerMessage.includes("create") || lowerMessage.includes("insert") || lowerMessage.includes("include"));
|
|
832
|
+
const asksPageRename = isPageRouteRenameRequest(message);
|
|
833
|
+
if ((intent.action === "update" || intent.action === "move" || intent.action === "clarify") && asksPageRename) {
|
|
834
|
+
const mentionsCurrentPage = /\b(this|current|the)\s+page\b/.test(lowerMessage);
|
|
835
|
+
let fromSlug = routeMentions[0] ?? slug;
|
|
836
|
+
let toSlug = routeMentions.length >= 2 ? routeMentions[routeMentions.length - 1] : undefined;
|
|
837
|
+
if (!toSlug && routeMentions.length === 1 && mentionsCurrentPage) {
|
|
838
|
+
toSlug = routeMentions[0];
|
|
839
|
+
fromSlug = slug;
|
|
840
|
+
}
|
|
841
|
+
// Extract natural language name after "to" when no route mentions resolve
|
|
842
|
+
let newTitle;
|
|
843
|
+
if (!toSlug || toSlug === fromSlug) {
|
|
844
|
+
// Strip clarification history and site context before extracting the target name
|
|
845
|
+
const msgForRename = message.replace(/\nClarification from user:[\s\S]*/i, "").replace(/\s*\[site context\][\s\S]*$/i, "").trim();
|
|
846
|
+
const nameMatch = msgForRename.match(/\brename\b.*\bpage\b.*?\bto\s+(.+)$/i)
|
|
847
|
+
?? msgForRename.match(/\brename\b.*?\bto\s+(.+)$/i);
|
|
848
|
+
if (nameMatch) {
|
|
849
|
+
const rawName = nameMatch[1].replace(/[.!?]+$/, "").trim();
|
|
850
|
+
if (rawName) {
|
|
851
|
+
const seed = toSeedSlug(rawName);
|
|
852
|
+
if (seed) {
|
|
853
|
+
toSlug = `/${seed}`;
|
|
854
|
+
fromSlug = slug;
|
|
855
|
+
newTitle = rawName.split(/\s+/).map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" ");
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
if (!toSlug || toSlug === fromSlug) {
|
|
861
|
+
return {
|
|
862
|
+
intent: "needs_clarification",
|
|
863
|
+
summary_for_user: st(args.locale, "rename.needsPath"),
|
|
864
|
+
change_log: assumptions,
|
|
865
|
+
ops: []
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const renameOp = { op: "rename_page", pageSlug: fromSlug, newPageSlug: toSlug, ...(newTitle ? { newTitle } : {}) };
|
|
869
|
+
return {
|
|
870
|
+
intent: "edit_plan",
|
|
871
|
+
summary_for_user: st(args.locale, "rename.done", { from: fromSlug, to: toSlug }),
|
|
872
|
+
change_log: [...assumptions, `Renamed page ${fromSlug} -> ${toSlug}.`],
|
|
873
|
+
ops: [renameOp]
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
const asksPageDelete = /\b(delete|remove)\b.*\bpage\b/.test(lowerMessage) && !messageRefersToBlock(cleanMessage, args.activeBlockId);
|
|
877
|
+
if ((intent.action === "remove" || intent.action === "clarify") && asksPageDelete) {
|
|
878
|
+
const targetSlug = routeMentions[0] ?? slug;
|
|
879
|
+
if (targetSlug === "/") {
|
|
880
|
+
return {
|
|
881
|
+
intent: "needs_clarification",
|
|
882
|
+
summary_for_user: st(args.locale, "delete.homeBlocked"),
|
|
883
|
+
change_log: assumptions,
|
|
884
|
+
ops: []
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
intent: "edit_plan",
|
|
889
|
+
summary_for_user: st(args.locale, "delete.done", { slug: targetSlug }),
|
|
890
|
+
change_log: [...assumptions, `Removed page ${targetSlug}.`],
|
|
891
|
+
ops: [{ op: "remove_page", pageSlug: targetSlug }]
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
const requestedCreateSlug = parseCreatePageRequest(message);
|
|
895
|
+
// When the user mentions "template", skip deterministic page creation
|
|
896
|
+
// and let the LLM planner use the template definitions from site context.
|
|
897
|
+
const mentionsTemplate = /\btemplate\b/i.test(cleanMessage);
|
|
898
|
+
if ((intent.action === "add" || intent.action === "clarify" || intent.action === "update") && requestedCreateSlug) {
|
|
899
|
+
if (mentionsTemplate)
|
|
900
|
+
return null; // defer to LLM planner
|
|
901
|
+
const createPlan = buildCreatePagePlan({ session, requestedSlug: requestedCreateSlug, assumptions, userMessage: message });
|
|
902
|
+
if (createPlan)
|
|
903
|
+
return createPlan;
|
|
904
|
+
}
|
|
905
|
+
const hasNavContext = /\b(nav|navigation|menu|first|last|position)\b/.test(lowerMessage) || routeMentions.length >= 2;
|
|
906
|
+
const asksNavMove = !messageRefersToBlock(cleanMessage, args.activeBlockId) && (/\b(nav|navigation|menu|tabs?|tab order|page order)\b/.test(lowerMessage) ||
|
|
907
|
+
/\bmove\b.*\btab\b/.test(lowerMessage) ||
|
|
908
|
+
(/\bmove\b.*\bpage\b/.test(lowerMessage) && hasNavContext) ||
|
|
909
|
+
/\breorder\b.*\b(page|nav|menu|tabs?)\b/.test(lowerMessage) ||
|
|
910
|
+
isNavMoveRequest(cleanMessage));
|
|
911
|
+
if ((intent.action === "move" || intent.action === "clarify") && asksNavMove) {
|
|
912
|
+
const sessionDraft = getSessionDraft(session);
|
|
913
|
+
const slugsRaw = Array.from(sessionDraft.keys());
|
|
914
|
+
const ordered = slugsRaw.includes("/") ? ["/", ...slugsRaw.filter((route) => route !== "/")] : slugsRaw;
|
|
915
|
+
const movedSlug = routeMentions[0] ?? slug;
|
|
916
|
+
if (!ordered.includes(movedSlug)) {
|
|
917
|
+
return {
|
|
918
|
+
intent: "needs_clarification",
|
|
919
|
+
summary_for_user: st(args.locale, "move.notFound", { slug: movedSlug }),
|
|
920
|
+
change_log: assumptions,
|
|
921
|
+
ops: []
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
if (movedSlug === "/") {
|
|
925
|
+
return {
|
|
926
|
+
intent: "needs_clarification",
|
|
927
|
+
summary_for_user: st(args.locale, "move.homeFixed"),
|
|
928
|
+
change_log: assumptions,
|
|
929
|
+
ops: []
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
// Extract anchor page name from natural language when no route mentions available
|
|
933
|
+
const resolveAnchorSlug = () => {
|
|
934
|
+
if (routeMentions.length >= 2)
|
|
935
|
+
return routeMentions[1];
|
|
936
|
+
// "move page before About us" → extract "About us"
|
|
937
|
+
const anchorMatch = cleanMessage.match(/\b(?:before|after|above|below|under)\s+(.+)$/i);
|
|
938
|
+
if (anchorMatch) {
|
|
939
|
+
const name = anchorMatch[1].replace(/\s*\[site context\][\s\S]*$/i, "").replace(/[.!?]+$/, "").trim();
|
|
940
|
+
return resolvePageSlugByTitle(sessionDraft, name);
|
|
941
|
+
}
|
|
942
|
+
return undefined;
|
|
943
|
+
};
|
|
944
|
+
let afterPageSlug;
|
|
945
|
+
if (/\b(top|first|start|beginning)\b/.test(lowerMessage)) {
|
|
946
|
+
afterPageSlug = undefined;
|
|
947
|
+
}
|
|
948
|
+
else if (/\b(bottom|last|end)\b/.test(lowerMessage)) {
|
|
949
|
+
const tail = [...ordered].reverse().find((route) => route !== movedSlug);
|
|
950
|
+
afterPageSlug = tail === "/" ? "/" : tail;
|
|
951
|
+
}
|
|
952
|
+
else if (/\b(after|below|under)\b/.test(lowerMessage)) {
|
|
953
|
+
const anchor = resolveAnchorSlug();
|
|
954
|
+
if (!anchor) {
|
|
955
|
+
return {
|
|
956
|
+
intent: "needs_clarification",
|
|
957
|
+
summary_for_user: st(args.locale, "move.specifyWhere"),
|
|
958
|
+
change_log: assumptions,
|
|
959
|
+
ops: []
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
afterPageSlug = anchor;
|
|
963
|
+
}
|
|
964
|
+
else if (/\b(before|above)\b/.test(lowerMessage)) {
|
|
965
|
+
const anchor = resolveAnchorSlug();
|
|
966
|
+
if (!anchor) {
|
|
967
|
+
return {
|
|
968
|
+
intent: "needs_clarification",
|
|
969
|
+
summary_for_user: st(args.locale, "move.specifyWhere"),
|
|
970
|
+
change_log: assumptions,
|
|
971
|
+
ops: []
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
if (anchor === "/")
|
|
975
|
+
afterPageSlug = undefined;
|
|
976
|
+
else {
|
|
977
|
+
const index = ordered.findIndex((route) => route === anchor);
|
|
978
|
+
if (index === -1) {
|
|
979
|
+
return {
|
|
980
|
+
intent: "needs_clarification",
|
|
981
|
+
summary_for_user: st(args.locale, "move.anchorNotFound", { slug: anchor }),
|
|
982
|
+
change_log: assumptions,
|
|
983
|
+
ops: []
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
const previous = ordered.slice(0, index).reverse().find((route) => route !== movedSlug);
|
|
987
|
+
afterPageSlug = previous === "/" ? "/" : previous;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
else if (routeMentions.length >= 2) {
|
|
991
|
+
afterPageSlug = routeMentions[1];
|
|
992
|
+
}
|
|
993
|
+
else {
|
|
994
|
+
return {
|
|
995
|
+
intent: "needs_clarification",
|
|
996
|
+
summary_for_user: st(args.locale, "move.specifyWhere"),
|
|
997
|
+
change_log: assumptions,
|
|
998
|
+
ops: []
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
return {
|
|
1002
|
+
intent: "edit_plan",
|
|
1003
|
+
summary_for_user: afterPageSlug === undefined
|
|
1004
|
+
? `Moved ${movedSlug} to the first nav position (after Home).`
|
|
1005
|
+
: `Moved ${movedSlug} after ${afterPageSlug}.`,
|
|
1006
|
+
change_log: [...assumptions, `Reordered nav: ${movedSlug}`],
|
|
1007
|
+
ops: [{ op: "move_page", pageSlug: movedSlug, afterPageSlug }]
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
const audiences = extractAudienceTargets(message);
|
|
1011
|
+
const audience = audiences[0];
|
|
1012
|
+
const asksAudienceCreatePage = audiences.length > 0 &&
|
|
1013
|
+
/\b(create|generate|build|make|draft)\b/.test(lowerMessage) &&
|
|
1014
|
+
/\b(pages?|landing pages?)\b/.test(lowerMessage);
|
|
1015
|
+
if (asksAudienceCreatePage && audience) {
|
|
1016
|
+
if (process.env.OPENAI_API_KEY)
|
|
1017
|
+
return null;
|
|
1018
|
+
const now = new Date().toISOString();
|
|
1019
|
+
const reservedSlugs = new Set(getSessionDraft(session).keys());
|
|
1020
|
+
const allocateSlug = (requested) => {
|
|
1021
|
+
let candidate = requested;
|
|
1022
|
+
if (!reservedSlugs.has(candidate)) {
|
|
1023
|
+
reservedSlugs.add(candidate);
|
|
1024
|
+
return candidate;
|
|
1025
|
+
}
|
|
1026
|
+
let idx = 2;
|
|
1027
|
+
while (reservedSlugs.has(`${requested}-${idx}`))
|
|
1028
|
+
idx += 1;
|
|
1029
|
+
candidate = `${requested}-${idx}`;
|
|
1030
|
+
reservedSlugs.add(candidate);
|
|
1031
|
+
return candidate;
|
|
1032
|
+
};
|
|
1033
|
+
const pages = audiences.map((aud, index) => {
|
|
1034
|
+
const seed = toSeedSlug(aud) || `audience-${index + 1}`;
|
|
1035
|
+
const requestedSlug = routeMentions[index] ?? `/for-${seed}`;
|
|
1036
|
+
const normalizedRequested = normalizeRouteCandidate(requestedSlug) ?? `/for-${seed}`;
|
|
1037
|
+
const newSlug = allocateSlug(normalizedRequested);
|
|
1038
|
+
const label = titleCaseWords(aud);
|
|
1039
|
+
const page = {
|
|
1040
|
+
id: `p_for_${seed}`,
|
|
1041
|
+
slug: newSlug,
|
|
1042
|
+
title: `For ${label}`,
|
|
1043
|
+
updatedAt: now,
|
|
1044
|
+
blocks: [
|
|
1045
|
+
{
|
|
1046
|
+
id: `b_hero_${seed}`,
|
|
1047
|
+
type: "Hero",
|
|
1048
|
+
props: {
|
|
1049
|
+
heading: `Built for ${label}`,
|
|
1050
|
+
subheading: `Everything on this page is tailored for ${aud}.`,
|
|
1051
|
+
ctaText: "Get Started",
|
|
1052
|
+
ctaHref: "/",
|
|
1053
|
+
imageUrl: IMAGE_PLACEHOLDER,
|
|
1054
|
+
imageAlt: `Audience-focused hero image for ${label}`
|
|
1055
|
+
}
|
|
1056
|
+
},
|
|
1057
|
+
{
|
|
1058
|
+
id: `b_features_${seed}`,
|
|
1059
|
+
type: "FeatureGrid",
|
|
1060
|
+
props: {
|
|
1061
|
+
title: `Why ${label} choose this`,
|
|
1062
|
+
features: [
|
|
1063
|
+
{ title: "Relevant messaging", description: `Copy aligned to ${aud} needs and language.` },
|
|
1064
|
+
{ title: "Clear outcomes", description: "Benefits are framed around practical results." },
|
|
1065
|
+
{ title: "Focused next step", description: "CTA is tuned for this audience journey." }
|
|
1066
|
+
]
|
|
1067
|
+
}
|
|
1068
|
+
},
|
|
1069
|
+
{
|
|
1070
|
+
id: `b_faq_${seed}`,
|
|
1071
|
+
type: "FAQAccordion",
|
|
1072
|
+
props: {
|
|
1073
|
+
title: `FAQ for ${label}`,
|
|
1074
|
+
items: [
|
|
1075
|
+
{ q: `Is this suitable for ${aud}?`, a: `Yes, this page is tailored for ${aud}.` },
|
|
1076
|
+
{ q: "How quickly can I start?", a: "Most visitors can get started in minutes." },
|
|
1077
|
+
{ q: "Can I customize later?", a: "Yes, content and sections can be updated anytime." }
|
|
1078
|
+
]
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
{
|
|
1082
|
+
id: `b_cta_${seed}`,
|
|
1083
|
+
type: "CTA",
|
|
1084
|
+
props: {
|
|
1085
|
+
title: `Start with a plan for ${label}`,
|
|
1086
|
+
description: "Take the next step with content designed for your audience.",
|
|
1087
|
+
ctaText: "Start now",
|
|
1088
|
+
ctaHref: "/"
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
]
|
|
1092
|
+
};
|
|
1093
|
+
return { page, audience: aud };
|
|
1094
|
+
});
|
|
1095
|
+
const createdAudienceLabels = pages.map((entry) => entry.audience);
|
|
1096
|
+
const summaryAudienceList = createdAudienceLabels.join(", ");
|
|
1097
|
+
return {
|
|
1098
|
+
intent: "edit_plan",
|
|
1099
|
+
summary_for_user: pages.length === 1
|
|
1100
|
+
? `Created a new page tailored for ${audience}.`
|
|
1101
|
+
: `Created ${pages.length} new pages tailored for ${summaryAudienceList}.`,
|
|
1102
|
+
change_log: [
|
|
1103
|
+
...assumptions,
|
|
1104
|
+
...pages.map((entry) => `Created page ${entry.page.slug} for audience: ${entry.audience}.`)
|
|
1105
|
+
],
|
|
1106
|
+
ops: pages.map((entry) => ({ op: "create_page", page: entry.page }))
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
const asksAudienceRetarget = Boolean(audience) &&
|
|
1110
|
+
!asksAudienceCreatePage &&
|
|
1111
|
+
(/\bfor\b/.test(lowerMessage) || /\baudience\b/.test(lowerMessage) || /\btarget\b/.test(lowerMessage));
|
|
1112
|
+
if (asksAudienceRetarget && audience) {
|
|
1113
|
+
const targets = selectedBlock
|
|
1114
|
+
? [selectedBlock]
|
|
1115
|
+
: currentPage.blocks.filter((block) => block.type === "Hero" || block.type === "CTA" || block.type === "RichText").slice(0, 3);
|
|
1116
|
+
if (process.env.OPENAI_API_KEY && !selectedBlock)
|
|
1117
|
+
return null;
|
|
1118
|
+
const ops = [];
|
|
1119
|
+
for (const block of targets) {
|
|
1120
|
+
const patch = audiencePatchForBlock(block, audience);
|
|
1121
|
+
if (Object.keys(patch).length === 0)
|
|
1122
|
+
continue;
|
|
1123
|
+
ops.push({ op: "update_props", pageSlug: slug, blockId: block.id, patch });
|
|
1124
|
+
}
|
|
1125
|
+
if (ops.length > 0) {
|
|
1126
|
+
return {
|
|
1127
|
+
intent: "edit_plan",
|
|
1128
|
+
summary_for_user: st(args.locale, "audience.done", { audience }),
|
|
1129
|
+
change_log: [...assumptions, `Retargeted copy for audience: ${audience}.`],
|
|
1130
|
+
ops
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
if (intent.action === "move" && hasConditionalQualifier && asksSectionReorder && !hasExplicitPlacementCue && !hasExplicitBlockMentionInMessage) {
|
|
1135
|
+
return {
|
|
1136
|
+
intent: "needs_clarification",
|
|
1137
|
+
summary_for_user: st(args.locale, "reorder.specify"),
|
|
1138
|
+
change_log: [...assumptions, "Skipped ambiguous conditional reorder request without explicit section or placement."],
|
|
1139
|
+
ops: []
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
if (selectedBlock?.type === "Hero" &&
|
|
1143
|
+
asksSecondaryCtaAdd &&
|
|
1144
|
+
(intent.action === "add" || intent.action === "clarify" || intent.action === "update")) {
|
|
1145
|
+
const heroProps = selectedBlock.props;
|
|
1146
|
+
const existingText = typeof heroProps.secondaryCtaText === "string" ? heroProps.secondaryCtaText.trim() : "";
|
|
1147
|
+
const existingHref = typeof heroProps.secondaryCtaHref === "string" ? heroProps.secondaryCtaHref.trim() : "";
|
|
1148
|
+
const quoted = quotedText(message);
|
|
1149
|
+
const patch = {
|
|
1150
|
+
secondaryCtaText: quoted ?? (existingText.length > 0 ? existingText : "Learn more"),
|
|
1151
|
+
secondaryCtaHref: existingHref.length > 0 ? existingHref : "/"
|
|
1152
|
+
};
|
|
1153
|
+
return {
|
|
1154
|
+
intent: "edit_plan",
|
|
1155
|
+
summary_for_user: st(args.locale, "hero.secondaryCta"),
|
|
1156
|
+
change_log: [...assumptions, `Updated ${selectedBlock.id}: secondaryCtaText, secondaryCtaHref`],
|
|
1157
|
+
ops: [{ op: "update_props", pageSlug: slug, blockId: selectedBlock.id, patch }]
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
// "each/every/all" signals a bulk update across items — needs LLM, not inline append
|
|
1161
|
+
const asksBulkUpdate = /\b(each|every|all)\b/.test(lowerMessage);
|
|
1162
|
+
const asksInlineAdd = !asksBulkUpdate &&
|
|
1163
|
+
lowerMessage.includes("add") &&
|
|
1164
|
+
(lowerMessage.includes("inside") ||
|
|
1165
|
+
lowerMessage.includes("within") ||
|
|
1166
|
+
lowerMessage.includes("current") ||
|
|
1167
|
+
lowerMessage.includes("this one") ||
|
|
1168
|
+
lowerMessage.includes("more") ||
|
|
1169
|
+
lowerMessage.includes("another"));
|
|
1170
|
+
if ((intent.action === "add" || intent.action === "clarify") && asksInlineAdd) {
|
|
1171
|
+
let inlineTarget = selectedBlock;
|
|
1172
|
+
let ambiguousContainerType = null;
|
|
1173
|
+
if (!inlineTarget) {
|
|
1174
|
+
const typeMap = [
|
|
1175
|
+
{ test: /\btestimonial/, type: "Testimonials" },
|
|
1176
|
+
{ test: /\b(faq|question)/, type: "FAQAccordion" },
|
|
1177
|
+
{ test: /\bfeature/, type: "FeatureGrid" },
|
|
1178
|
+
{ test: /\bcard/, type: "CardGrid" },
|
|
1179
|
+
{ test: /\btab/, type: "Tabs" },
|
|
1180
|
+
{ test: /\bslide/, type: "Carousel" },
|
|
1181
|
+
{ test: /\bstat/, type: "Stats" },
|
|
1182
|
+
{ test: /\brow/, type: "Table" }
|
|
1183
|
+
];
|
|
1184
|
+
for (const entry of typeMap) {
|
|
1185
|
+
if (entry.test.test(lowerMessage)) {
|
|
1186
|
+
const matches = currentPage.blocks.filter((b) => b.type === entry.type);
|
|
1187
|
+
// Keep scanning when this item word names a container that isn't on the
|
|
1188
|
+
// page: "add another card to the features section" mentions "feature"
|
|
1189
|
+
// (no FeatureGrid here) but the cards live in a CardGrid, matched by the
|
|
1190
|
+
// later "card" entry — breaking on the first match stranded it (#19).
|
|
1191
|
+
if (matches.length === 1) {
|
|
1192
|
+
inlineTarget = matches[0];
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
// Referenced type exists but isn't unique (e.g. 3 CardGrids) — remember it
|
|
1196
|
+
// so we can defer rather than fabricate a new container below.
|
|
1197
|
+
if (matches.length > 1 && !ambiguousContainerType)
|
|
1198
|
+
ambiguousContainerType = entry.type;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (inlineTarget) {
|
|
1203
|
+
const patch = buildListAppendPatch(inlineTarget, message);
|
|
1204
|
+
if (patch) {
|
|
1205
|
+
return {
|
|
1206
|
+
intent: "edit_plan",
|
|
1207
|
+
summary_for_user: st(args.locale, "update.done", { type: blockLabel(inlineTarget.type) }),
|
|
1208
|
+
change_log: [...assumptions, `Added one entry to ${blockLabel(inlineTarget.type)}.`],
|
|
1209
|
+
ops: [{ op: "update_props", pageSlug: slug, blockId: inlineTarget.id, patch }]
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
// Clear "append another item to the <X>" intent, but the target container can't
|
|
1214
|
+
// be resolved deterministically (multiple of that type) — defer to the LLM to
|
|
1215
|
+
// pick the right block by heading instead of falling through and adding a phantom
|
|
1216
|
+
// empty container (#19). Without this the request creates a junk Card/FeatureGrid.
|
|
1217
|
+
if (!inlineTarget && ambiguousContainerType)
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
if (intent.action === "info") {
|
|
1221
|
+
// Let the full planner handle info queries — it produces detailed
|
|
1222
|
+
// block descriptions via the submit_edit_plan tool.
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
if (intent.action === "clarify" && !activeEditablePath) {
|
|
1226
|
+
return {
|
|
1227
|
+
intent: "needs_clarification",
|
|
1228
|
+
summary_for_user: intent.summary ?? st(args.locale, "update.specify"),
|
|
1229
|
+
change_log: assumptions,
|
|
1230
|
+
ops: []
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
// "remove all except X" — generate remove ops for every block NOT matching the kept type
|
|
1234
|
+
if (intent.action === "remove" && EXCEPT_PATTERN.test(cleanMessage)) {
|
|
1235
|
+
const keepType = intent.target_block_type;
|
|
1236
|
+
if (keepType) {
|
|
1237
|
+
const toRemove = currentPage.blocks.filter((b) => b.type !== keepType);
|
|
1238
|
+
if (toRemove.length === 0) {
|
|
1239
|
+
return {
|
|
1240
|
+
intent: "edit_plan",
|
|
1241
|
+
summary_for_user: st(args.locale, "remove.allAlready", { type: blockLabel(keepType) }),
|
|
1242
|
+
change_log: assumptions,
|
|
1243
|
+
ops: []
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
return {
|
|
1247
|
+
intent: "edit_plan",
|
|
1248
|
+
summary_for_user: intent.summary ?? `Removed all blocks except ${keepType}.`,
|
|
1249
|
+
change_log: [
|
|
1250
|
+
...assumptions,
|
|
1251
|
+
...toRemove.map((b) => `Removed ${blockLabel(b.type)}.`)
|
|
1252
|
+
],
|
|
1253
|
+
ops: toRemove.map((b) => ({ op: "remove_block", pageSlug: slug, blockId: b.id }))
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (intent.action === "remove") {
|
|
1258
|
+
let target = resolveBlockRef({
|
|
1259
|
+
ref: intent.target_block_ref,
|
|
1260
|
+
currentPage,
|
|
1261
|
+
activeBlockId,
|
|
1262
|
+
fallbackType: intent.target_block_type
|
|
1263
|
+
});
|
|
1264
|
+
if (!target && !activeBlockId) {
|
|
1265
|
+
const inferredType = inferBlockTypeFromText(cleanMessage);
|
|
1266
|
+
if (inferredType) {
|
|
1267
|
+
const matches = currentPage.blocks.filter((b) => b.type === inferredType);
|
|
1268
|
+
if (matches.length === 1)
|
|
1269
|
+
target = matches[0];
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
if (!target) {
|
|
1273
|
+
return {
|
|
1274
|
+
intent: "needs_clarification",
|
|
1275
|
+
summary_for_user: st(args.locale, "remove.needsBlock"),
|
|
1276
|
+
change_log: assumptions,
|
|
1277
|
+
ops: []
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
// Detect item-level removal (e.g., "remove the first question from the FAQ")
|
|
1281
|
+
// Skip when the user wants to remove image *properties* (e.g. "remove images from cards")
|
|
1282
|
+
const asksRemoveImageProps = /\bremove\b.*\b(images?|photos?|pictures?)\b.*\b(from|in|on)\b/i.test(lowerMessage);
|
|
1283
|
+
// The ambiguous triggers below ("card", "feature", ordinals) ALSO appear when the
|
|
1284
|
+
// user is selecting among sibling CONTAINER BLOCKS ("delete the second card grid",
|
|
1285
|
+
// "remove the first feature grid", "hide the third section"). Removing an item there
|
|
1286
|
+
// is destructive and wrong — it deletes one card and shifts the array while the
|
|
1287
|
+
// summary claims the whole block is gone (#31). Treat it as a block removal when the
|
|
1288
|
+
// message names a container block as the target and isn't scoped INTO the container
|
|
1289
|
+
// ("the last card IN the grid" / "a question FROM the faq" stay item ops).
|
|
1290
|
+
const namesContainerAsTarget = /\b(?:card\s*grids?|feature\s*grids?|grids?|two[\s-]?columns?|carousels?|galler(?:y|ies)|accordions?|sections?|blocks?)\b/i.test(lowerMessage);
|
|
1291
|
+
const itemScopedIntoContainer = /\b(?:cards?|questions?|items?|entries|entry|testimonials?|features?|slides?|rows?|stats?|tabs?|points?|photos?|images?)\s+(?:in|inside|within|from|of|on)\b/i.test(lowerMessage) ||
|
|
1292
|
+
isContainerScopedRemove(lowerMessage);
|
|
1293
|
+
const removesWholeContainer = namesContainerAsTarget && !itemScopedIntoContainer;
|
|
1294
|
+
// "remove all the card grids" / "delete every feature grid" — every block of the
|
|
1295
|
+
// target's type, not one item. Emit a remove_block per matching block (#31).
|
|
1296
|
+
if (removesWholeContainer && /\b(?:all|every|each)\b/i.test(lowerMessage)) {
|
|
1297
|
+
const matches = currentPage.blocks.filter((b) => b.type === target.type);
|
|
1298
|
+
if (matches.length > 0) {
|
|
1299
|
+
return {
|
|
1300
|
+
intent: "edit_plan",
|
|
1301
|
+
summary_for_user: intent.summary ?? st(args.locale, "remove.done", { type: blockLabel(target.type) }),
|
|
1302
|
+
change_log: [...assumptions, ...matches.map(() => `Removed ${blockLabel(target.type)}.`)],
|
|
1303
|
+
ops: matches.map((b) => ({ op: "remove_block", pageSlug: slug, blockId: b.id }))
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
const asksRemoveItem = !asksRemoveImageProps && !removesWholeContainer && /\b(question|item|entry|testimonial|card|feature|first|second|third|last)\b/i.test(lowerMessage);
|
|
1308
|
+
if (asksRemoveItem) {
|
|
1309
|
+
const bProps = target.props;
|
|
1310
|
+
if (bProps) {
|
|
1311
|
+
for (const [key, val] of Object.entries(bProps)) {
|
|
1312
|
+
if (!Array.isArray(val) || val.length === 0)
|
|
1313
|
+
continue;
|
|
1314
|
+
// Use ordinalToIndex for consistent ordinal parsing
|
|
1315
|
+
const ordMatch = lowerMessage.match(/\b(first|second|third|fourth|fifth|last|1st|2nd|3rd|4th|5th)\b/i);
|
|
1316
|
+
const parsedIdx = ordMatch ? ordinalToIndex(ordMatch[1]) : 0;
|
|
1317
|
+
if (parsedIdx === null) {
|
|
1318
|
+
return {
|
|
1319
|
+
intent: "needs_clarification",
|
|
1320
|
+
summary_for_user: st(args.locale, "remove.specifyItem"),
|
|
1321
|
+
change_log: assumptions,
|
|
1322
|
+
ops: []
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
const idx = parsedIdx === -1 ? val.length - 1 : parsedIdx;
|
|
1326
|
+
if (idx >= val.length) {
|
|
1327
|
+
return {
|
|
1328
|
+
intent: "needs_clarification",
|
|
1329
|
+
summary_for_user: st(args.locale, "remove.outOfRange", { count: String(val.length), type: blockLabel(target.type), index: String(idx + 1) }),
|
|
1330
|
+
change_log: assumptions,
|
|
1331
|
+
ops: []
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
return {
|
|
1335
|
+
intent: "edit_plan",
|
|
1336
|
+
summary_for_user: intent.summary ?? st(args.locale, "remove.itemDone", { type: blockLabel(target.type) }),
|
|
1337
|
+
change_log: [...assumptions, `Removed item ${idx + 1} from ${blockLabel(target.type)}.`],
|
|
1338
|
+
ops: [{ op: "remove_item", pageSlug: slug, blockId: target.id, listKey: key, index: idx }]
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
intent: "edit_plan",
|
|
1345
|
+
summary_for_user: intent.summary ?? st(args.locale, "remove.done", { type: blockLabel(target.type) }),
|
|
1346
|
+
change_log: [...assumptions, `Removed ${blockLabel(target.type)}.`],
|
|
1347
|
+
ops: [{ op: "remove_block", pageSlug: slug, blockId: target.id }]
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
if (intent.action === "update" || (intent.action === "clarify" && !!activeEditablePath)) {
|
|
1351
|
+
const target = resolveBlockRef({
|
|
1352
|
+
ref: intent.target_block_ref,
|
|
1353
|
+
currentPage,
|
|
1354
|
+
activeBlockId,
|
|
1355
|
+
fallbackType: intent.target_block_type
|
|
1356
|
+
});
|
|
1357
|
+
if (!target) {
|
|
1358
|
+
return {
|
|
1359
|
+
intent: "needs_clarification",
|
|
1360
|
+
summary_for_user: st(args.locale, "update.needsBlock"),
|
|
1361
|
+
change_log: assumptions,
|
|
1362
|
+
ops: []
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
const childPatch = coercePatchForEditablePath(target, activeEditablePath, intent.patch, message);
|
|
1366
|
+
const fullPatch = coercePatchForBlock(target, intent.patch);
|
|
1367
|
+
const mergedRichTextTranslationPatch = shouldKeepRichTextTitleOnTranslate({
|
|
1368
|
+
target,
|
|
1369
|
+
activeEditablePath,
|
|
1370
|
+
message,
|
|
1371
|
+
fullPatch
|
|
1372
|
+
})
|
|
1373
|
+
? { ...fullPatch, ...(childPatch?.patch ?? {}) }
|
|
1374
|
+
: null;
|
|
1375
|
+
const patch = mergedRichTextTranslationPatch ?? childPatch?.patch ?? fullPatch;
|
|
1376
|
+
if (Object.keys(patch).length === 0) {
|
|
1377
|
+
const editableFields = userFacingPropNames(target.type, Object.keys(target.props));
|
|
1378
|
+
return {
|
|
1379
|
+
intent: "needs_clarification",
|
|
1380
|
+
summary_for_user: st(args.locale, "update.invalidFields", { type: blockLabel(target.type) }),
|
|
1381
|
+
change_log: [...assumptions, `Editable fields: ${editableFields.join(", ")}`],
|
|
1382
|
+
ops: []
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
const changedKeys = userFacingPropNames(target.type, Object.keys(patch));
|
|
1386
|
+
return {
|
|
1387
|
+
intent: "edit_plan",
|
|
1388
|
+
summary_for_user: intent.summary ?? st(args.locale, "update.done", { type: blockLabel(target.type) }),
|
|
1389
|
+
change_log: [
|
|
1390
|
+
...assumptions,
|
|
1391
|
+
childPatch
|
|
1392
|
+
? `Updated ${blockLabel(target.type)} ${activeEditablePath}: ${changedKeys.join(", ")}`
|
|
1393
|
+
: `Updated ${blockLabel(target.type)}: ${changedKeys.join(", ")}`
|
|
1394
|
+
],
|
|
1395
|
+
ops: [{ op: "update_props", pageSlug: slug, blockId: target.id, patch }]
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
if (intent.action === "move") {
|
|
1399
|
+
const target = resolveBlockRef({
|
|
1400
|
+
ref: intent.target_block_ref,
|
|
1401
|
+
currentPage,
|
|
1402
|
+
activeBlockId,
|
|
1403
|
+
fallbackType: intent.target_block_type
|
|
1404
|
+
});
|
|
1405
|
+
if (!target) {
|
|
1406
|
+
return {
|
|
1407
|
+
intent: "needs_clarification",
|
|
1408
|
+
summary_for_user: st(args.locale, "move.needsBlock"),
|
|
1409
|
+
change_log: assumptions,
|
|
1410
|
+
ops: []
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
let afterBlockId;
|
|
1414
|
+
if (intent.position === "top") {
|
|
1415
|
+
afterBlockId = undefined;
|
|
1416
|
+
}
|
|
1417
|
+
else if (intent.position === "bottom") {
|
|
1418
|
+
const tail = [...currentPage.blocks].reverse().find((b) => b.id !== target.id);
|
|
1419
|
+
afterBlockId = tail?.id;
|
|
1420
|
+
}
|
|
1421
|
+
else if (intent.position === "after" || (intent.anchor_block_ref && !intent.position)) {
|
|
1422
|
+
const anchor = resolveBlockRef({ ref: intent.anchor_block_ref, currentPage });
|
|
1423
|
+
if (!anchor) {
|
|
1424
|
+
return {
|
|
1425
|
+
intent: "needs_clarification",
|
|
1426
|
+
summary_for_user: st(args.locale, "move.anchorBlockNotFound"),
|
|
1427
|
+
change_log: assumptions,
|
|
1428
|
+
ops: []
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
afterBlockId = anchor.id;
|
|
1432
|
+
}
|
|
1433
|
+
else if (intent.position === "before") {
|
|
1434
|
+
const anchor = resolveBlockRef({ ref: intent.anchor_block_ref, currentPage });
|
|
1435
|
+
if (!anchor) {
|
|
1436
|
+
return {
|
|
1437
|
+
intent: "needs_clarification",
|
|
1438
|
+
summary_for_user: st(args.locale, "move.anchorBlockBeforeNotFound"),
|
|
1439
|
+
change_log: assumptions,
|
|
1440
|
+
ops: []
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
const idx = currentPage.blocks.findIndex((b) => b.id === anchor.id);
|
|
1444
|
+
if (idx > 0)
|
|
1445
|
+
afterBlockId = currentPage.blocks[idx - 1]?.id;
|
|
1446
|
+
else
|
|
1447
|
+
afterBlockId = undefined;
|
|
1448
|
+
}
|
|
1449
|
+
else if (message.toLowerCase().includes("bottom") || message.toLowerCase().includes("end")) {
|
|
1450
|
+
const tail = [...currentPage.blocks].reverse().find((b) => b.id !== target.id);
|
|
1451
|
+
afterBlockId = tail?.id;
|
|
1452
|
+
}
|
|
1453
|
+
else {
|
|
1454
|
+
return {
|
|
1455
|
+
intent: "needs_clarification",
|
|
1456
|
+
summary_for_user: st(args.locale, "move.specifyDirection"),
|
|
1457
|
+
change_log: assumptions,
|
|
1458
|
+
ops: []
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
return {
|
|
1462
|
+
intent: "edit_plan",
|
|
1463
|
+
summary_for_user: intent.summary ?? st(args.locale, "move.blockDone", { type: blockLabel(target.type) }),
|
|
1464
|
+
change_log: [...assumptions, `Moved ${blockLabel(target.type)}.`],
|
|
1465
|
+
ops: [{ op: "move_block", pageSlug: slug, blockId: target.id, afterBlockId }]
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
if (intent.action === "add") {
|
|
1469
|
+
// --- Batch add: "add 3 blocks: hero, cardgrid and CTA" ----------------
|
|
1470
|
+
if (isBatchAddRequest(message)) {
|
|
1471
|
+
// Strip any trailing positional anchor so a real batch ("add a hero and a
|
|
1472
|
+
// cta below the header") doesn't also create the anchor block (#18).
|
|
1473
|
+
const blockTypes = extractMentionedBlockTypes(stripPositionalAnchorPhrase(message));
|
|
1474
|
+
if (blockTypes.length >= 2) {
|
|
1475
|
+
const ops = [];
|
|
1476
|
+
const changeLog = [...assumptions];
|
|
1477
|
+
// Track blocks as we add them so nextBlockId generates unique IDs
|
|
1478
|
+
let pageSnapshot = currentPage;
|
|
1479
|
+
for (const bt of blockTypes) {
|
|
1480
|
+
const blockId = nextBlockId(bt, pageSnapshot);
|
|
1481
|
+
const props = defaultPropsForType(bt);
|
|
1482
|
+
ops.push({ op: "add_block", pageSlug: slug, block: { id: blockId, type: bt, props } });
|
|
1483
|
+
changeLog.push(`Added ${blockLabel(bt)} block.`);
|
|
1484
|
+
// Update snapshot so the next nextBlockId sees existing IDs
|
|
1485
|
+
pageSnapshot = { ...pageSnapshot, blocks: [...pageSnapshot.blocks, { id: blockId, type: bt, props }] };
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
intent: "edit_plan",
|
|
1489
|
+
summary_for_user: st(args.locale, "add.multiple", { types: blockTypes.map(blockLabel).join(", ") }),
|
|
1490
|
+
change_log: changeLog,
|
|
1491
|
+
ops
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
// --- Single add -------------------------------------------------------
|
|
1496
|
+
// Scan the message with any trailing positional anchor removed so "add a
|
|
1497
|
+
// contact form below the hero" does not resolve to the anchor's type (Hero)
|
|
1498
|
+
// and silently create a phantom duplicate (#18).
|
|
1499
|
+
const addText = stripPositionalAnchorPhrase(message);
|
|
1500
|
+
const blockType = intent.new_block_type ??
|
|
1501
|
+
inferAddedBlockTypeFromMessage(addText) ??
|
|
1502
|
+
intent.target_block_type ??
|
|
1503
|
+
inferBlockTypeFromText(intent.target_block_ref ?? "") ??
|
|
1504
|
+
inferBlockTypeFromText(addText);
|
|
1505
|
+
if (!blockType) {
|
|
1506
|
+
// No block type found — check if this is an image replacement request
|
|
1507
|
+
// e.g. "add unsplash image", "add a new photo", "add image"
|
|
1508
|
+
const isImageRequest = /\b(image|photo|picture)\b/.test(lowerMessage);
|
|
1509
|
+
if (isImageRequest) {
|
|
1510
|
+
const hero = selectedBlock?.type === "Hero"
|
|
1511
|
+
? selectedBlock
|
|
1512
|
+
: currentPage.blocks.find((b) => b.type === "Hero") ?? null;
|
|
1513
|
+
if (hero) {
|
|
1514
|
+
return {
|
|
1515
|
+
intent: "edit_plan",
|
|
1516
|
+
summary_for_user: st(args.locale, "add.heroImage"),
|
|
1517
|
+
change_log: [...assumptions, `Updated ${blockLabel(hero.type)}: image`],
|
|
1518
|
+
ops: [{ op: "update_props", pageSlug: slug, blockId: hero.id, patch: { imageUrl: "pending" } }]
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return {
|
|
1523
|
+
intent: "needs_clarification",
|
|
1524
|
+
summary_for_user: st(args.locale, "add.specifyType", { types: allowedBlockTypes.map(blockLabel).join(", ") }),
|
|
1525
|
+
change_log: assumptions,
|
|
1526
|
+
ops: []
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
// Check if this is an item-level addition to an existing list block.
|
|
1530
|
+
// An explicit positional placement ("add a testimonials section below the
|
|
1531
|
+
// hero") always means a NEW block at that spot — never an item appended to an
|
|
1532
|
+
// existing one — so skip the append path when an anchor/position is set (#18).
|
|
1533
|
+
const explicitPlacement = intent.position != null || intent.anchor_block_ref != null;
|
|
1534
|
+
// "add an FAQ section" / "add a testimonials section" names a NEW block to
|
|
1535
|
+
// create, even when one of that type already exists — the block noun
|
|
1536
|
+
// ("section"/"block") is the object of the add. Only count it when it appears
|
|
1537
|
+
// BEFORE any "to the <container>" clause, so "add a testimonial to the
|
|
1538
|
+
// testimonials section" (an item appended into a container) still appends (#15).
|
|
1539
|
+
const addObject = lowerMessage.split(/\bto\s+the\b/)[0] ?? lowerMessage;
|
|
1540
|
+
const wantsNewBlock = /\b(?:section|block)\b/.test(addObject);
|
|
1541
|
+
const existingOfType = currentPage.blocks.filter((b) => b.type === blockType);
|
|
1542
|
+
if (existingOfType.length === 1 && !explicitPlacement && !wantsNewBlock) {
|
|
1543
|
+
const existing = existingOfType[0];
|
|
1544
|
+
const listPatch = buildListAppendPatch(existing, message);
|
|
1545
|
+
if (listPatch) {
|
|
1546
|
+
return {
|
|
1547
|
+
intent: "edit_plan",
|
|
1548
|
+
summary_for_user: intent.summary ?? st(args.locale, "add.itemDone", { type: blockLabel(existing.type) }),
|
|
1549
|
+
change_log: [...assumptions, `Appended item to ${blockLabel(existing.type)}.`],
|
|
1550
|
+
ops: [{ op: "update_props", pageSlug: slug, blockId: existing.id, patch: listPatch }]
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// "add a <child> to/into the <existing block>" — e.g. "add a video to the hero",
|
|
1555
|
+
// "add a button to the CTA", "add a card to the classes grid". A same-type item
|
|
1556
|
+
// append was already handled just above; reaching here means the target names an
|
|
1557
|
+
// existing block of a DIFFERENT type, so the user wants to put something INSIDE (or
|
|
1558
|
+
// onto) that block, not create a new top-level one. The deterministic scan can't model
|
|
1559
|
+
// that and tends to mistake the target's type for the block to create — a phantom
|
|
1560
|
+
// duplicate Hero/CTA, or a stray Card. Hand these to the LLM, which disambiguates (#28).
|
|
1561
|
+
const intoClause = lowerMessage.match(/\b(?:in|into|to)\s+the\s+([a-z][a-z0-9 ]*)/);
|
|
1562
|
+
if (intoClause && !wantsNewBlock && !explicitPlacement) {
|
|
1563
|
+
const targetPhrase = intoClause[1];
|
|
1564
|
+
const targetType = inferBlockTypeFromText(targetPhrase) ?? inferContainerTypeFromNoun(targetPhrase);
|
|
1565
|
+
const targetsPageOrPosition = /\b(page|site|top|bottom|end|start|beginning|middle)\b/.test(targetPhrase);
|
|
1566
|
+
if (targetType != null && !targetsPageOrPosition && currentPage.blocks.some((b) => b.type === targetType)) {
|
|
1567
|
+
return null;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
const blockId = nextBlockId(blockType, currentPage);
|
|
1571
|
+
const baseProps = defaultPropsForType(blockType);
|
|
1572
|
+
const patch = coercePatchForBlock({ id: blockId, type: blockType, props: baseProps }, intent.patch);
|
|
1573
|
+
const props = { ...baseProps, ...patch };
|
|
1574
|
+
const addOp = {
|
|
1575
|
+
op: "add_block",
|
|
1576
|
+
pageSlug: slug,
|
|
1577
|
+
block: { id: blockId, type: blockType, props }
|
|
1578
|
+
};
|
|
1579
|
+
let extraMoveTop = null;
|
|
1580
|
+
if (intent.position === "after" || (intent.anchor_block_ref && !intent.position)) {
|
|
1581
|
+
const anchor = resolveBlockRef({ ref: intent.anchor_block_ref, currentPage });
|
|
1582
|
+
if (!anchor) {
|
|
1583
|
+
return {
|
|
1584
|
+
intent: "needs_clarification",
|
|
1585
|
+
summary_for_user: st(args.locale, "add.anchorAfterNotFound"),
|
|
1586
|
+
change_log: assumptions,
|
|
1587
|
+
ops: []
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
addOp.afterBlockId = anchor.id;
|
|
1591
|
+
}
|
|
1592
|
+
else if (intent.position === "before") {
|
|
1593
|
+
const anchor = resolveBlockRef({ ref: intent.anchor_block_ref, currentPage });
|
|
1594
|
+
if (!anchor) {
|
|
1595
|
+
return {
|
|
1596
|
+
intent: "needs_clarification",
|
|
1597
|
+
summary_for_user: st(args.locale, "add.anchorBeforeNotFound"),
|
|
1598
|
+
change_log: assumptions,
|
|
1599
|
+
ops: []
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
const idx = currentPage.blocks.findIndex((b) => b.id === anchor.id);
|
|
1603
|
+
if (idx > 0)
|
|
1604
|
+
addOp.afterBlockId = currentPage.blocks[idx - 1]?.id;
|
|
1605
|
+
else
|
|
1606
|
+
extraMoveTop = { op: "move_block", pageSlug: slug, blockId, afterBlockId: undefined };
|
|
1607
|
+
}
|
|
1608
|
+
else if (intent.position === "top") {
|
|
1609
|
+
extraMoveTop = { op: "move_block", pageSlug: slug, blockId, afterBlockId: undefined };
|
|
1610
|
+
}
|
|
1611
|
+
else if (intent.position === "bottom" || !intent.position) {
|
|
1612
|
+
// no-op: add without anchor appends to bottom in applyOpsAtomically
|
|
1613
|
+
}
|
|
1614
|
+
const ops = extraMoveTop ? [addOp, extraMoveTop] : [addOp];
|
|
1615
|
+
return {
|
|
1616
|
+
intent: "edit_plan",
|
|
1617
|
+
summary_for_user: intent.summary ?? st(args.locale, "add.done", { type: blockLabel(blockType) }),
|
|
1618
|
+
change_log: [...assumptions, `Added ${blockLabel(blockType)} block.`],
|
|
1619
|
+
ops
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1624
|
+
// ---------------------------------------------------------------------------
|
|
1625
|
+
// Re-exports from split files (preserve public API)
|
|
1626
|
+
// ---------------------------------------------------------------------------
|
|
1627
|
+
export { extractAudienceTarget, extractAudienceTargets, titleCaseWords, addAudienceSuffix, audiencePatchForBlock, coercePatchForBlock, parseIndexedPath, inferSimpleFieldPatchFromMessage, isRewriteRequest, isTranslationRequest, shouldKeepRichTextTitleOnTranslate, inferFieldHintFromMessage, rewriteFromExisting, coercePatchForEditablePath, quotedText, buildListAppendPatch } from "./deterministic-planner-patches.js";
|
|
1628
|
+
export { editablePropsFromBlock, promptFromPropKey, userFacingPropNames, ORDINALS, humanizeArrayPath, childSuggestions, clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, titleCaseSentence, pageMetaContractSummary, blockContractsSummary } from "./deterministic-planner-suggestions.js";
|
|
1629
|
+
export { nextAvailableSlug, createPageBlocks, buildCreatePagePlan, isPageRouteRenameRequest } from "./deterministic-planner-pages.js";
|
|
1630
|
+
export { readPathValue, resolveImageUrlForAltField, fetchImageAsBase64, resolveAttachmentsForLlm, selectedBlockSnapshot, arrayPropLengths, pageIntentSummary, plannerContextPack } from "./deterministic-planner-context.js";
|
|
1631
|
+
export { inferAddedBlockTypeFromMessage, resolveBlockRef, ordinalToIndex, resolveByDescriptor, resolveReferencesFromMessage } from "./deterministic-planner-refs.js";
|