@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,1537 @@
|
|
|
1
|
+
import { allowedBlockTypes, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Op reordering: create_page must precede ops targeting the same slug
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
function reorderCreatePageFirst(ops) {
|
|
7
|
+
const createOps = [];
|
|
8
|
+
for (let i = 0; i < ops.length; i++) {
|
|
9
|
+
const op = ops[i];
|
|
10
|
+
if (!op || typeof op !== "object")
|
|
11
|
+
continue;
|
|
12
|
+
if (op.op !== "create_page")
|
|
13
|
+
continue;
|
|
14
|
+
const page = op.page;
|
|
15
|
+
const slug = typeof page?.slug === "string" ? page.slug : undefined;
|
|
16
|
+
if (slug)
|
|
17
|
+
createOps.push({ index: i, slug });
|
|
18
|
+
}
|
|
19
|
+
if (createOps.length === 0)
|
|
20
|
+
return ops;
|
|
21
|
+
const createSlugs = new Set(createOps.map((c) => c.slug));
|
|
22
|
+
const result = [];
|
|
23
|
+
const deferred = [];
|
|
24
|
+
for (let i = 0; i < ops.length; i++) {
|
|
25
|
+
const op = ops[i];
|
|
26
|
+
if (!op || typeof op !== "object") {
|
|
27
|
+
result.push(ops[i]);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (op.op === "create_page") {
|
|
31
|
+
result.push(op);
|
|
32
|
+
// Flush any deferred ops that target this page
|
|
33
|
+
const page = op.page;
|
|
34
|
+
const slug = typeof page?.slug === "string" ? page.slug : undefined;
|
|
35
|
+
if (slug) {
|
|
36
|
+
const flushed = [];
|
|
37
|
+
for (let j = 0; j < deferred.length; j++) {
|
|
38
|
+
const d = deferred[j];
|
|
39
|
+
if (d.pageSlug === slug) {
|
|
40
|
+
result.push(d);
|
|
41
|
+
flushed.push(j);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (let j = flushed.length - 1; j >= 0; j--)
|
|
45
|
+
deferred.splice(flushed[j], 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (typeof op.pageSlug === "string" && createSlugs.has(op.pageSlug) && !result.some((r) => {
|
|
49
|
+
const ro = r;
|
|
50
|
+
return ro && ro.op === "create_page" && ro.page?.slug === op.pageSlug;
|
|
51
|
+
})) {
|
|
52
|
+
// This op targets a page that will be created later — defer it
|
|
53
|
+
deferred.push(op);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
result.push(op);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Append any remaining deferred ops (shouldn't happen if create_page exists)
|
|
60
|
+
result.push(...deferred);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// JSON extraction & repair
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
export function extractJsonObject(input) {
|
|
67
|
+
const start = input.indexOf("{");
|
|
68
|
+
const end = input.lastIndexOf("}");
|
|
69
|
+
if (start === -1 || end === -1 || end <= start)
|
|
70
|
+
return null;
|
|
71
|
+
return input.slice(start, end + 1);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Attempt to repair common JSON malformations from LLM output:
|
|
75
|
+
* - Bare `-` used as markdown bullets inside arrays: `[- "item"]` → `["item"]`
|
|
76
|
+
* - Trailing commas before `]` or `}`
|
|
77
|
+
* Returns repaired string or original if no repair was needed.
|
|
78
|
+
*/
|
|
79
|
+
export function repairJson(raw) {
|
|
80
|
+
let result = raw;
|
|
81
|
+
// Escape unescaped control characters (newlines, tabs, etc.) inside JSON
|
|
82
|
+
// string values. LLMs with eager_input_streaming often emit literal control
|
|
83
|
+
// chars in markdown-formatted text.
|
|
84
|
+
result = escapeControlCharsInStrings(result);
|
|
85
|
+
// Fix markdown bullets inside JSON arrays: [- "...", - "..."] → ["...", "..."]
|
|
86
|
+
// Match `- ` after `[` or `,` (with optional whitespace) when not inside a string
|
|
87
|
+
result = result.replace(/(\[|,)\s*-\s+(?=")/g, "$1 ");
|
|
88
|
+
// Fix trailing commas: [item,] or {key: value,}
|
|
89
|
+
result = result.replace(/,\s*([}\]])/g, "$1");
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Escape unescaped control characters (newlines, tabs, etc.) that appear
|
|
94
|
+
* inside JSON string values. Walks character-by-character tracking string
|
|
95
|
+
* context so control chars outside strings are left untouched.
|
|
96
|
+
*/
|
|
97
|
+
function escapeControlCharsInStrings(raw) {
|
|
98
|
+
const out = [];
|
|
99
|
+
let inString = false;
|
|
100
|
+
let escaped = false;
|
|
101
|
+
for (let i = 0; i < raw.length; i++) {
|
|
102
|
+
const code = raw.charCodeAt(i);
|
|
103
|
+
if (escaped) {
|
|
104
|
+
escaped = false;
|
|
105
|
+
out.push(raw[i]);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (raw[i] === "\\") {
|
|
109
|
+
escaped = true;
|
|
110
|
+
out.push(raw[i]);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (raw[i] === '"') {
|
|
114
|
+
inString = !inString;
|
|
115
|
+
out.push(raw[i]);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (inString && code <= 0x1f) {
|
|
119
|
+
// Replace unescaped control characters inside strings
|
|
120
|
+
if (code === 0x0a) {
|
|
121
|
+
out.push("\\n");
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (code === 0x0d) {
|
|
125
|
+
out.push("\\r");
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (code === 0x09) {
|
|
129
|
+
out.push("\\t");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
out.push("\\u" + code.toString(16).padStart(4, "0"));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
out.push(raw[i]);
|
|
136
|
+
}
|
|
137
|
+
return out.join("");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Close unclosed braces, brackets, and strings in a truncated JSON string.
|
|
141
|
+
* Walks the string tracking nesting context and appends the necessary
|
|
142
|
+
* closing characters.
|
|
143
|
+
*/
|
|
144
|
+
function closeOpenBrackets(input) {
|
|
145
|
+
const stack = [];
|
|
146
|
+
let inStr = false;
|
|
147
|
+
let esc = false;
|
|
148
|
+
for (const ch of input) {
|
|
149
|
+
if (esc) {
|
|
150
|
+
esc = false;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (ch === "\\") {
|
|
154
|
+
esc = true;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (ch === '"') {
|
|
158
|
+
inStr = !inStr;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (inStr)
|
|
162
|
+
continue;
|
|
163
|
+
if (ch === "{")
|
|
164
|
+
stack.push("}");
|
|
165
|
+
else if (ch === "[")
|
|
166
|
+
stack.push("]");
|
|
167
|
+
else if (ch === "}" || ch === "]")
|
|
168
|
+
stack.pop();
|
|
169
|
+
}
|
|
170
|
+
let result = input;
|
|
171
|
+
if (inStr)
|
|
172
|
+
result += '"';
|
|
173
|
+
while (stack.length > 0)
|
|
174
|
+
result += stack.pop();
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Attempt multiple JSON repair strategies. Returns the first one that
|
|
179
|
+
* produces parseable JSON along with metadata about what was done,
|
|
180
|
+
* or throws the original error.
|
|
181
|
+
*/
|
|
182
|
+
export function repairAndParseJsonWithMeta(raw) {
|
|
183
|
+
// Strategy 1: basic repair (includes control char escaping + markdown bullet fix)
|
|
184
|
+
const basic = repairJson(raw);
|
|
185
|
+
try {
|
|
186
|
+
return { parsed: JSON.parse(basic), strategy: "basic_repair" };
|
|
187
|
+
}
|
|
188
|
+
catch { }
|
|
189
|
+
// Strategy 2: fix "no number after minus sign" by replacing bare `-`
|
|
190
|
+
// outside of string literals with `0`. Walk character by character to
|
|
191
|
+
// track whether we're inside a JSON string.
|
|
192
|
+
try {
|
|
193
|
+
const chars = [...basic];
|
|
194
|
+
let inString = false;
|
|
195
|
+
let escaped = false;
|
|
196
|
+
let mutations = 0;
|
|
197
|
+
for (let i = 0; i < chars.length; i++) {
|
|
198
|
+
if (escaped) {
|
|
199
|
+
escaped = false;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (chars[i] === "\\") {
|
|
203
|
+
escaped = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (chars[i] === '"') {
|
|
207
|
+
inString = !inString;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!inString && chars[i] === "-") {
|
|
211
|
+
const next = chars[i + 1];
|
|
212
|
+
if (next === undefined || !/\d/.test(next)) {
|
|
213
|
+
// Bare minus not followed by digit — replace with 0
|
|
214
|
+
chars[i] = "0";
|
|
215
|
+
mutations += 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { parsed: JSON.parse(chars.join("")), strategy: "bare_minus_fix", mutationCount: mutations };
|
|
220
|
+
}
|
|
221
|
+
catch { }
|
|
222
|
+
// Strategy 3: truncated output — close open braces/brackets in correct order
|
|
223
|
+
try {
|
|
224
|
+
return { parsed: JSON.parse(closeOpenBrackets(basic)), strategy: "close_brackets" };
|
|
225
|
+
}
|
|
226
|
+
catch { }
|
|
227
|
+
// Strategy 4: truncate at last valid key-value boundary
|
|
228
|
+
// The model produced valid JSON for some fields but then broke into raw
|
|
229
|
+
// markdown (e.g., bare bullet lists instead of JSON arrays). Collect all
|
|
230
|
+
// comma positions outside strings, then try truncating at each from right
|
|
231
|
+
// to left until we get valid JSON.
|
|
232
|
+
try {
|
|
233
|
+
const commaPositions = [];
|
|
234
|
+
let inStr4 = false;
|
|
235
|
+
let esc4 = false;
|
|
236
|
+
for (let i = 0; i < basic.length; i++) {
|
|
237
|
+
if (esc4) {
|
|
238
|
+
esc4 = false;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (basic[i] === "\\") {
|
|
242
|
+
esc4 = true;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (basic[i] === '"') {
|
|
246
|
+
inStr4 = !inStr4;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (!inStr4 && basic[i] === ",")
|
|
250
|
+
commaPositions.push(i);
|
|
251
|
+
}
|
|
252
|
+
// Try last 10 positions max — truncating further is unlikely to yield useful content
|
|
253
|
+
const startIdx = Math.max(0, commaPositions.length - 10);
|
|
254
|
+
for (let ci = commaPositions.length - 1; ci >= startIdx; ci--) {
|
|
255
|
+
const truncated = basic.slice(0, commaPositions[ci]);
|
|
256
|
+
try {
|
|
257
|
+
return { parsed: JSON.parse(closeOpenBrackets(truncated)), strategy: "truncate_at_boundary", discardedBytes: raw.length - commaPositions[ci] };
|
|
258
|
+
}
|
|
259
|
+
catch { }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
catch { }
|
|
263
|
+
// All strategies failed — throw original parse error
|
|
264
|
+
return { parsed: JSON.parse(raw), strategy: "none" };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Attempt multiple JSON repair strategies. Returns the first one that
|
|
268
|
+
* produces parseable JSON, or throws the original error.
|
|
269
|
+
*/
|
|
270
|
+
export function repairAndParseJson(raw) {
|
|
271
|
+
return repairAndParseJsonWithMeta(raw).parsed;
|
|
272
|
+
}
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Operation name normalisation
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
export function normalizeOpName(op) {
|
|
277
|
+
if (typeof op !== "string")
|
|
278
|
+
return op;
|
|
279
|
+
const key = op.toLowerCase().replace(/[\s-]/g, "_");
|
|
280
|
+
const aliases = {
|
|
281
|
+
create: "create_page",
|
|
282
|
+
create_page: "create_page",
|
|
283
|
+
createpage: "create_page",
|
|
284
|
+
add: "add_block",
|
|
285
|
+
add_block: "add_block",
|
|
286
|
+
addblock: "add_block",
|
|
287
|
+
insert_block: "add_block",
|
|
288
|
+
insertblock: "add_block",
|
|
289
|
+
update: "update_props",
|
|
290
|
+
update_props: "update_props",
|
|
291
|
+
updateprops: "update_props",
|
|
292
|
+
update_block: "update_props",
|
|
293
|
+
updateblock: "update_props",
|
|
294
|
+
edit_block: "update_props",
|
|
295
|
+
editblock: "update_props",
|
|
296
|
+
remove: "remove_block",
|
|
297
|
+
remove_block: "remove_block",
|
|
298
|
+
removeblock: "remove_block",
|
|
299
|
+
delete: "remove_block",
|
|
300
|
+
delete_block: "remove_block",
|
|
301
|
+
deleteblock: "remove_block",
|
|
302
|
+
move: "move_block",
|
|
303
|
+
move_block: "move_block",
|
|
304
|
+
moveblock: "move_block",
|
|
305
|
+
reorder_block: "move_block",
|
|
306
|
+
reorderblock: "move_block",
|
|
307
|
+
duplicate_block: "duplicate_block",
|
|
308
|
+
duplicateblock: "duplicate_block",
|
|
309
|
+
copy_block: "duplicate_block",
|
|
310
|
+
copyblock: "duplicate_block",
|
|
311
|
+
clone_block: "duplicate_block",
|
|
312
|
+
cloneblock: "duplicate_block",
|
|
313
|
+
add_item: "add_item",
|
|
314
|
+
additem: "add_item",
|
|
315
|
+
insert_item: "add_item",
|
|
316
|
+
insertitem: "add_item",
|
|
317
|
+
append_item: "add_item",
|
|
318
|
+
appenditem: "add_item",
|
|
319
|
+
update_item: "update_item",
|
|
320
|
+
updateitem: "update_item",
|
|
321
|
+
edit_item: "update_item",
|
|
322
|
+
edititem: "update_item",
|
|
323
|
+
remove_item: "remove_item",
|
|
324
|
+
removeitem: "remove_item",
|
|
325
|
+
delete_item: "remove_item",
|
|
326
|
+
deleteitem: "remove_item",
|
|
327
|
+
move_item: "move_item",
|
|
328
|
+
moveitem: "move_item",
|
|
329
|
+
reorder_item: "move_item",
|
|
330
|
+
reorderitem: "move_item",
|
|
331
|
+
// Plural reorders are the declarative whole-list/whole-page ops; the
|
|
332
|
+
// singular aliases above stay on move_item/move_block (one element moved).
|
|
333
|
+
reorder_items: "reorder_items",
|
|
334
|
+
reorderitems: "reorder_items",
|
|
335
|
+
sort_items: "reorder_items",
|
|
336
|
+
sortitems: "reorder_items",
|
|
337
|
+
reorder_list: "reorder_items",
|
|
338
|
+
reorderlist: "reorder_items",
|
|
339
|
+
reorder_blocks: "reorder_blocks",
|
|
340
|
+
reorderblocks: "reorder_blocks",
|
|
341
|
+
sort_blocks: "reorder_blocks",
|
|
342
|
+
sortblocks: "reorder_blocks",
|
|
343
|
+
reorder_sections: "reorder_blocks",
|
|
344
|
+
reordersections: "reorder_blocks",
|
|
345
|
+
move_page: "move_page",
|
|
346
|
+
movepage: "move_page",
|
|
347
|
+
reorder_page: "move_page",
|
|
348
|
+
reorderpage: "move_page",
|
|
349
|
+
duplicate_page: "duplicate_page",
|
|
350
|
+
duplicatepage: "duplicate_page",
|
|
351
|
+
copy_page: "duplicate_page",
|
|
352
|
+
copypage: "duplicate_page",
|
|
353
|
+
clone_page: "duplicate_page",
|
|
354
|
+
clonepage: "duplicate_page",
|
|
355
|
+
rename: "rename_page",
|
|
356
|
+
rename_page: "rename_page",
|
|
357
|
+
renamepage: "rename_page",
|
|
358
|
+
remove_page: "remove_page",
|
|
359
|
+
removepage: "remove_page",
|
|
360
|
+
delete_page: "remove_page",
|
|
361
|
+
deletepage: "remove_page",
|
|
362
|
+
update_site_config: "update_site_config",
|
|
363
|
+
updatesiteconfig: "update_site_config",
|
|
364
|
+
update_theme: "update_theme",
|
|
365
|
+
updatetheme: "update_theme",
|
|
366
|
+
set_theme: "update_theme",
|
|
367
|
+
update_styles: "update_theme",
|
|
368
|
+
update_page_meta: "update_page_meta",
|
|
369
|
+
updatepagemeta: "update_page_meta"
|
|
370
|
+
};
|
|
371
|
+
return aliases[key] ?? op;
|
|
372
|
+
}
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// Slug / page-id helpers
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
export function pageIdFromSlug(slug) {
|
|
377
|
+
if (slug === "/")
|
|
378
|
+
return "p_home";
|
|
379
|
+
const core = slug
|
|
380
|
+
.slice(1)
|
|
381
|
+
.replace(/[^a-z0-9]+/gi, "_")
|
|
382
|
+
.replace(/^_+|_+$/g, "");
|
|
383
|
+
return `p_${core || "page"}`;
|
|
384
|
+
}
|
|
385
|
+
export function pageTitleFromSlug(slug) {
|
|
386
|
+
if (slug === "/")
|
|
387
|
+
return "Home";
|
|
388
|
+
const text = slug
|
|
389
|
+
.slice(1)
|
|
390
|
+
.split("/")
|
|
391
|
+
.filter(Boolean)
|
|
392
|
+
.map((part) => part.replace(/[-_]/g, " "))
|
|
393
|
+
.join(" ")
|
|
394
|
+
.trim();
|
|
395
|
+
if (!text)
|
|
396
|
+
return "Untitled Page";
|
|
397
|
+
return text
|
|
398
|
+
.split(/\s+/)
|
|
399
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
400
|
+
.join(" ");
|
|
401
|
+
}
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Block type inference
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
export function inferBlockTypeFromText(text) {
|
|
406
|
+
const normalized = text.toLowerCase();
|
|
407
|
+
if (normalized.includes("hero"))
|
|
408
|
+
return "Hero";
|
|
409
|
+
if (normalized.includes("featuregrid") || normalized.includes("feature grid") || normalized.includes("features") || /\bfeture/.test(normalized))
|
|
410
|
+
return "FeatureGrid";
|
|
411
|
+
if (normalized.includes("testimonial") || normalized.includes("testomonial") || normalized.includes("social proof") || normalized.includes("review") || (/\bquote\b/.test(normalized) && !normalized.includes("blockquote")))
|
|
412
|
+
return "Testimonials";
|
|
413
|
+
if (normalized.includes("faq"))
|
|
414
|
+
return "FAQAccordion";
|
|
415
|
+
if (normalized.includes("twocolumn") || normalized.includes("two column") || normalized.includes("2 column"))
|
|
416
|
+
return "TwoColumn";
|
|
417
|
+
if (normalized.includes("stats") || normalized.includes("statistics") || normalized.includes("metrics") || normalized.includes("numbers"))
|
|
418
|
+
return "Stats";
|
|
419
|
+
if (normalized.includes("cta") || normalized.includes("call-to-action") || normalized.includes("call to action"))
|
|
420
|
+
return "CTA";
|
|
421
|
+
if (normalized.includes("cardgrid") || normalized.includes("card grid") || normalized.includes("pricing"))
|
|
422
|
+
return "CardGrid";
|
|
423
|
+
if (normalized.includes("card"))
|
|
424
|
+
return "Card";
|
|
425
|
+
if (normalized.includes("richtext") || normalized.includes("rich text") || normalized.includes("rich-text") || normalized.includes("prose") || normalized.includes("text block") || normalized.includes("paragraph") || normalized.includes("copy"))
|
|
426
|
+
return "RichText";
|
|
427
|
+
if (normalized.includes("benefit") || normalized.includes("advantage"))
|
|
428
|
+
return "FeatureGrid";
|
|
429
|
+
if (normalized.includes("carousel") || normalized.includes("slideshow") || normalized.includes("slider"))
|
|
430
|
+
return "Carousel";
|
|
431
|
+
if (normalized.includes("gallery") || normalized.includes("image grid"))
|
|
432
|
+
return "Gallery";
|
|
433
|
+
if (normalized.includes("tabs") || normalized.includes("tabbed"))
|
|
434
|
+
return "Tabs";
|
|
435
|
+
if (normalized.includes("table"))
|
|
436
|
+
return "Table";
|
|
437
|
+
if (normalized.includes("blockquote"))
|
|
438
|
+
return "Quote";
|
|
439
|
+
if (normalized.includes("video"))
|
|
440
|
+
return "Video";
|
|
441
|
+
if (normalized.includes("embed") || normalized.includes("iframe") || normalized.includes("map"))
|
|
442
|
+
return "Embed";
|
|
443
|
+
if (normalized.includes("banner") || normalized.includes("announcement"))
|
|
444
|
+
return "Banner";
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Block ID generation
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
export function nextBlockId(type, page) {
|
|
451
|
+
const base = `b_${type.toLowerCase()}_${Date.now()}`;
|
|
452
|
+
if (!page.blocks.some((b) => b.id === base))
|
|
453
|
+
return base;
|
|
454
|
+
let i = 1;
|
|
455
|
+
while (page.blocks.some((b) => b.id === `${base}_${i}`))
|
|
456
|
+
i += 1;
|
|
457
|
+
return `${base}_${i}`;
|
|
458
|
+
}
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// Default block props
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
export function defaultPropsForType(type) {
|
|
463
|
+
return sharedDefaultPropsForType(type);
|
|
464
|
+
}
|
|
465
|
+
// ---------------------------------------------------------------------------
|
|
466
|
+
// Patch helpers
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
export function patchObject(rawPatch) {
|
|
469
|
+
if (!rawPatch || typeof rawPatch !== "object" || Array.isArray(rawPatch))
|
|
470
|
+
return null;
|
|
471
|
+
if ("props" in rawPatch &&
|
|
472
|
+
rawPatch.props &&
|
|
473
|
+
typeof rawPatch.props === "object" &&
|
|
474
|
+
!Array.isArray(rawPatch.props)) {
|
|
475
|
+
return rawPatch.props;
|
|
476
|
+
}
|
|
477
|
+
return rawPatch;
|
|
478
|
+
}
|
|
479
|
+
function parseListItemPath(path) {
|
|
480
|
+
if (typeof path !== "string")
|
|
481
|
+
return null;
|
|
482
|
+
const trimmed = path.trim();
|
|
483
|
+
const match = trimmed.match(/^\/?([a-zA-Z0-9_]+)\[(\d+)\](?:\.[\w.-]+)?$/);
|
|
484
|
+
if (!match)
|
|
485
|
+
return null;
|
|
486
|
+
const listKey = match[1];
|
|
487
|
+
const index = Number(match[2]);
|
|
488
|
+
if (!listKey || !Number.isInteger(index) || index < 0)
|
|
489
|
+
return null;
|
|
490
|
+
return { listKey, index };
|
|
491
|
+
}
|
|
492
|
+
/** Look up the stable `id` of the list item at `index`, if it has one. */
|
|
493
|
+
function resolveItemIdAtIndex(listValue, index) {
|
|
494
|
+
if (!Array.isArray(listValue) || index < 0 || index >= listValue.length)
|
|
495
|
+
return undefined;
|
|
496
|
+
const entry = listValue[index];
|
|
497
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
498
|
+
return undefined;
|
|
499
|
+
const id = entry.id;
|
|
500
|
+
return typeof id === "string" && id ? id : undefined;
|
|
501
|
+
}
|
|
502
|
+
function inferPreferredListItemTextKey(allowedKeys) {
|
|
503
|
+
const lowered = new Map(allowedKeys.map((key) => [key.toLowerCase(), key]));
|
|
504
|
+
const preference = ["title", "label", "q", "quote", "heading", "description", "author", "value"];
|
|
505
|
+
for (const candidate of preference) {
|
|
506
|
+
const mapped = lowered.get(candidate);
|
|
507
|
+
if (mapped)
|
|
508
|
+
return mapped;
|
|
509
|
+
}
|
|
510
|
+
return allowedKeys[0];
|
|
511
|
+
}
|
|
512
|
+
function remapListItemPatchKeys(args) {
|
|
513
|
+
const patch = patchObject(args.patchRaw);
|
|
514
|
+
if (!patch || args.allowedKeys.length === 0)
|
|
515
|
+
return patch;
|
|
516
|
+
const loweredToAllowed = new Map();
|
|
517
|
+
for (const key of args.allowedKeys)
|
|
518
|
+
loweredToAllowed.set(key.toLowerCase(), key);
|
|
519
|
+
const preferredTextKey = inferPreferredListItemTextKey(args.allowedKeys);
|
|
520
|
+
const aliasToPreferred = new Set(["label", "name", "text", "heading", "headline"]);
|
|
521
|
+
const out = {};
|
|
522
|
+
for (const [rawKey, value] of Object.entries(patch)) {
|
|
523
|
+
const trimmed = rawKey.trim();
|
|
524
|
+
const lowered = trimmed.toLowerCase();
|
|
525
|
+
const mapped = loweredToAllowed.get(lowered) ??
|
|
526
|
+
(aliasToPreferred.has(lowered) ? preferredTextKey : undefined);
|
|
527
|
+
out[mapped ?? trimmed] = value;
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// Plan candidate normalisation
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
export function normalizePlanCandidate(input, args) {
|
|
535
|
+
if (!input || typeof input !== "object")
|
|
536
|
+
return input;
|
|
537
|
+
const root = input;
|
|
538
|
+
const hasInlineOp = !Array.isArray(root.ops) && !Array.isArray(root.operations) && Boolean(root.op ?? root.operation ?? root.action ?? root.kind);
|
|
539
|
+
const ops = Array.isArray(root.ops) ? root.ops : Array.isArray(root.operations) ? root.operations : hasInlineOp ? [root] : [];
|
|
540
|
+
const userMessage = (args?.userMessage ?? "").toLowerCase();
|
|
541
|
+
const requestedRoute = firstRouteMention(args?.userMessage);
|
|
542
|
+
const routeMentions = extractRouteMentions(args?.userMessage);
|
|
543
|
+
const requestedCreateSlug = parseCreatePageRequest(args?.userMessage ?? "");
|
|
544
|
+
const createPageIntent = Boolean(requestedCreateSlug);
|
|
545
|
+
const refersToCurrentPage = /\b(this|current|selected|the)\s+page\b/.test(userMessage);
|
|
546
|
+
const resolvePageSlug = (candidate) => {
|
|
547
|
+
const normalized = normalizeRouteCandidate(candidate);
|
|
548
|
+
if (normalized)
|
|
549
|
+
return normalized;
|
|
550
|
+
if (args?.currentPage) {
|
|
551
|
+
if (typeof candidate !== "string")
|
|
552
|
+
return args?.defaultSlug;
|
|
553
|
+
if (candidate === args.currentPage.id)
|
|
554
|
+
return args.currentPage.slug;
|
|
555
|
+
if (candidate.toLowerCase() === "home" && args.currentPage.slug === "/")
|
|
556
|
+
return "/";
|
|
557
|
+
}
|
|
558
|
+
return args?.defaultSlug;
|
|
559
|
+
};
|
|
560
|
+
const beforeToAfter = (beforeId) => {
|
|
561
|
+
if (!args?.currentPage || typeof beforeId !== "string")
|
|
562
|
+
return undefined;
|
|
563
|
+
const idx = args.currentPage.blocks.findIndex((block) => block.id === beforeId);
|
|
564
|
+
if (idx <= 0)
|
|
565
|
+
return undefined;
|
|
566
|
+
return args.currentPage.blocks[idx - 1]?.id;
|
|
567
|
+
};
|
|
568
|
+
const usedBlockIds = new Set((args?.currentPage?.blocks ?? []).map((b) => b.id));
|
|
569
|
+
let createdPageSlug;
|
|
570
|
+
let droppedPageLevelUpdate = false;
|
|
571
|
+
// itemIds already claimed by a remove_item freeze in THIS plan — used to spot
|
|
572
|
+
// sequential-index plans (`remove(2), remove(2)`) and keep their raw indexes.
|
|
573
|
+
const frozenRemovedItemIds = new Set();
|
|
574
|
+
const normalizedOps = ops.flatMap((item) => {
|
|
575
|
+
if (!item || typeof item !== "object")
|
|
576
|
+
return item;
|
|
577
|
+
const source = item;
|
|
578
|
+
const raw = { ...source };
|
|
579
|
+
// Accept malformed one-key op objects like { "move_block": { ...fields } }.
|
|
580
|
+
if (!raw.op && !raw.operation && !raw.action && !raw.kind) {
|
|
581
|
+
for (const key of [
|
|
582
|
+
"create_page",
|
|
583
|
+
"add_block",
|
|
584
|
+
"update_props",
|
|
585
|
+
"remove_block",
|
|
586
|
+
"move_block",
|
|
587
|
+
"duplicate_block",
|
|
588
|
+
"add_item",
|
|
589
|
+
"update_item",
|
|
590
|
+
"remove_item",
|
|
591
|
+
"move_item",
|
|
592
|
+
"reorder_items",
|
|
593
|
+
"reorder_blocks",
|
|
594
|
+
"rename_page",
|
|
595
|
+
"remove_page",
|
|
596
|
+
"move_page",
|
|
597
|
+
"duplicate_page"
|
|
598
|
+
]) {
|
|
599
|
+
const value = source[key];
|
|
600
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
601
|
+
Object.assign(raw, value);
|
|
602
|
+
raw.op = key;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
raw.op = normalizeOpName(raw.op ?? raw.operation ?? raw.action ?? raw.kind);
|
|
608
|
+
const rawType = raw.type ?? raw.blockType ?? raw.block_type ?? raw.newBlockType ?? raw.new_block_type ?? raw.target_block_type ?? raw.targetBlockType;
|
|
609
|
+
const normalizedType = typeof rawType === "string"
|
|
610
|
+
? allowedBlockTypes.find((type) => type.toLowerCase() === rawType.toLowerCase()) ?? inferBlockTypeFromText(rawType)
|
|
611
|
+
: undefined;
|
|
612
|
+
const isListOperation = raw.op === "add_item" || raw.op === "update_item" || raw.op === "remove_item" || raw.op === "move_item" || raw.op === "reorder_items";
|
|
613
|
+
const pathLooksLikeListKey = typeof raw.path === "string" && !raw.path.startsWith("/");
|
|
614
|
+
const listPathParsed = parseListItemPath(raw.itemPath ?? raw.item_path ?? raw.itempath ?? raw.itemPtr ?? raw.item_ptr) ??
|
|
615
|
+
parseListItemPath(raw.path);
|
|
616
|
+
raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.page_slug ?? raw.slug ?? raw.page ?? (isListOperation ? undefined : raw.path) ?? raw.route ?? raw.from);
|
|
617
|
+
raw.newPageSlug = normalizeRouteCandidate(raw.newPageSlug ?? raw.new_page_slug ?? raw.targetSlug ?? raw.target_slug ?? raw.toPageSlug ?? raw.to_page_slug ?? raw.to);
|
|
618
|
+
if (!raw.blockId) {
|
|
619
|
+
const pathCandidate = typeof raw.path === "string" && raw.path.startsWith("b_") ? raw.path : undefined;
|
|
620
|
+
raw.blockId =
|
|
621
|
+
raw.block_id ??
|
|
622
|
+
raw.targetBlockId ??
|
|
623
|
+
raw.target_block_id ??
|
|
624
|
+
raw.sourceBlockId ??
|
|
625
|
+
raw.source_block_id ??
|
|
626
|
+
raw.fromBlockId ??
|
|
627
|
+
raw.from_block_id ??
|
|
628
|
+
raw.id ??
|
|
629
|
+
pathCandidate;
|
|
630
|
+
}
|
|
631
|
+
// Infer blockId from currentPage when op needs it but model omitted it
|
|
632
|
+
if (!raw.blockId && args?.currentPage) {
|
|
633
|
+
const needsBlockId = raw.op === "update_props" || raw.op === "remove_block" || raw.op === "move_block" || raw.op === "duplicate_block";
|
|
634
|
+
if (needsBlockId) {
|
|
635
|
+
// Try type from explicit op fields first, then from user message keywords
|
|
636
|
+
const typeHint = normalizedType ?? inferBlockTypeFromText(userMessage);
|
|
637
|
+
if (typeHint) {
|
|
638
|
+
const matches = args.currentPage.blocks.filter((b) => b.type === typeHint);
|
|
639
|
+
if (matches.length === 1) {
|
|
640
|
+
raw.blockId = matches[0].id;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// Fallback: if patch keys clearly map to only one block's props
|
|
644
|
+
if (!raw.blockId && raw.op === "update_props") {
|
|
645
|
+
const patch = patchObject(raw.patch ?? raw.props ?? raw.changes);
|
|
646
|
+
if (patch) {
|
|
647
|
+
const patchKeys = Object.keys(patch);
|
|
648
|
+
const candidates = args.currentPage.blocks.filter((b) => {
|
|
649
|
+
const bProps = b.props;
|
|
650
|
+
if (!bProps)
|
|
651
|
+
return false;
|
|
652
|
+
return patchKeys.some((k) => k in bProps);
|
|
653
|
+
});
|
|
654
|
+
if (candidates.length === 1)
|
|
655
|
+
raw.blockId = candidates[0].id;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (!raw.listKey) {
|
|
661
|
+
raw.listKey =
|
|
662
|
+
raw.list_key ??
|
|
663
|
+
raw.arrayKey ??
|
|
664
|
+
raw.array_key ??
|
|
665
|
+
raw.arrayProp ??
|
|
666
|
+
raw.array_prop ??
|
|
667
|
+
raw.collection ??
|
|
668
|
+
raw.itemsKey ??
|
|
669
|
+
raw.items_key ??
|
|
670
|
+
listPathParsed?.listKey;
|
|
671
|
+
if (!raw.listKey && isListOperation && pathLooksLikeListKey)
|
|
672
|
+
raw.listKey = raw.path;
|
|
673
|
+
if (!raw.listKey && isListOperation && typeof raw.path === "string") {
|
|
674
|
+
const keyCandidate = raw.path.trim().replace(/^\/+/, "");
|
|
675
|
+
if (keyCandidate && !keyCandidate.includes("/"))
|
|
676
|
+
raw.listKey = keyCandidate;
|
|
677
|
+
}
|
|
678
|
+
// Infer listKey from the target block's props when there is exactly one array field
|
|
679
|
+
if (!raw.listKey && isListOperation && typeof raw.blockId === "string" && args?.currentPage) {
|
|
680
|
+
const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
|
|
681
|
+
const targetProps = targetBlock?.props;
|
|
682
|
+
if (targetProps) {
|
|
683
|
+
const arrayKeys = Object.keys(targetProps).filter((k) => Array.isArray(targetProps[k]));
|
|
684
|
+
if (arrayKeys.length === 1)
|
|
685
|
+
raw.listKey = arrayKeys[0];
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (isListOperation && typeof raw.listKey === "string")
|
|
690
|
+
raw.listKey = raw.listKey.replace(/^\/+/, "");
|
|
691
|
+
if (isListOperation && typeof raw.listKey === "string" && typeof raw.pageSlug === "string" && raw.pageSlug === `/${raw.listKey}` && args?.defaultSlug) {
|
|
692
|
+
raw.pageSlug = args.defaultSlug;
|
|
693
|
+
}
|
|
694
|
+
if (typeof raw.index !== "number") {
|
|
695
|
+
const indexRaw = raw.index ?? raw.itemIndex ?? raw.item_index ?? raw.fromIndex ?? raw.from_index ?? listPathParsed?.index;
|
|
696
|
+
const normalizedIndex = typeof indexRaw === "string" ? Number(indexRaw) : indexRaw;
|
|
697
|
+
if (typeof normalizedIndex === "number" && Number.isFinite(normalizedIndex))
|
|
698
|
+
raw.index = Math.trunc(normalizedIndex);
|
|
699
|
+
}
|
|
700
|
+
if (typeof raw.afterIndex !== "number") {
|
|
701
|
+
const afterIndexRaw = raw.afterIndex ?? raw.after_index ?? raw.toIndex ?? raw.to_index ?? raw.targetIndex ?? raw.target_index;
|
|
702
|
+
const normalizedAfter = typeof afterIndexRaw === "string" ? Number(afterIndexRaw) : afterIndexRaw;
|
|
703
|
+
if (typeof normalizedAfter === "number" && Number.isFinite(normalizedAfter))
|
|
704
|
+
raw.afterIndex = Math.trunc(normalizedAfter);
|
|
705
|
+
}
|
|
706
|
+
// Stable item-id addressing (preferred over positional index). Accept common
|
|
707
|
+
// snake_case / alias spellings the LLM might emit.
|
|
708
|
+
if (typeof raw.itemId !== "string") {
|
|
709
|
+
const idRaw = raw.item_id ?? raw.targetItemId ?? raw.target_item_id;
|
|
710
|
+
if (typeof idRaw === "string" && idRaw)
|
|
711
|
+
raw.itemId = idRaw;
|
|
712
|
+
}
|
|
713
|
+
if (typeof raw.afterItemId !== "string") {
|
|
714
|
+
const afterIdRaw = raw.after_item_id ?? raw.afterId ?? raw.after_id;
|
|
715
|
+
if (typeof afterIdRaw === "string" && afterIdRaw)
|
|
716
|
+
raw.afterItemId = afterIdRaw;
|
|
717
|
+
}
|
|
718
|
+
// Freeze positional addressing into stable item ids at plan time. The planner
|
|
719
|
+
// reasons about indexes from get_page output, but a numeric index reindexes
|
|
720
|
+
// the moment an earlier op in the same plan mutates the list. Resolving
|
|
721
|
+
// index→itemId here — against the pre-mutation draft — makes compound plans
|
|
722
|
+
// ("delete the 2nd feature and reword the 4th") target the items the planner
|
|
723
|
+
// meant, not whatever later slides into those slots.
|
|
724
|
+
//
|
|
725
|
+
// Two guards:
|
|
726
|
+
// - pageSlug: only freeze ops that target the CURRENT page — cross-page ops
|
|
727
|
+
// (authorized since 573f644) may hit a same-named blockId/listKey on a
|
|
728
|
+
// different page, and resolving against the current page's list would
|
|
729
|
+
// freeze an id that doesn't exist over there.
|
|
730
|
+
// - sequential-remove dedupe: an LLM that emits `remove(2), remove(2)`
|
|
731
|
+
// means "remove two adjacent items" (post-mutation indexes). If a second
|
|
732
|
+
// remove_item would freeze to an id an earlier remove_item in this plan
|
|
733
|
+
// already claimed, skip the freeze and let the raw index apply
|
|
734
|
+
// sequentially instead of hard-failing on a double-delete.
|
|
735
|
+
const freezeTargetsCurrentPage = args?.currentPage && (typeof raw.pageSlug !== "string" || raw.pageSlug.length === 0 || raw.pageSlug === args.currentPage.slug);
|
|
736
|
+
if ((raw.op === "update_item" || raw.op === "remove_item" || raw.op === "move_item") &&
|
|
737
|
+
typeof raw.itemId !== "string" &&
|
|
738
|
+
typeof raw.index === "number" &&
|
|
739
|
+
typeof raw.blockId === "string" &&
|
|
740
|
+
typeof raw.listKey === "string" &&
|
|
741
|
+
freezeTargetsCurrentPage) {
|
|
742
|
+
const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
|
|
743
|
+
const listValue = targetBlock?.props?.[raw.listKey];
|
|
744
|
+
const frozen = resolveItemIdAtIndex(listValue, raw.index);
|
|
745
|
+
if (frozen) {
|
|
746
|
+
const claimKey = `${raw.blockId}|${raw.listKey}|${frozen}`;
|
|
747
|
+
if (raw.op === "remove_item" && frozenRemovedItemIds.has(claimKey)) {
|
|
748
|
+
// sequential-index semantics — leave the raw index in place
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
if (raw.op === "remove_item")
|
|
752
|
+
frozenRemovedItemIds.add(claimKey);
|
|
753
|
+
raw.itemId = frozen;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (raw.op === "move_item" &&
|
|
758
|
+
typeof raw.afterItemId !== "string" &&
|
|
759
|
+
typeof raw.afterIndex === "number" &&
|
|
760
|
+
typeof raw.blockId === "string" &&
|
|
761
|
+
typeof raw.listKey === "string" &&
|
|
762
|
+
freezeTargetsCurrentPage) {
|
|
763
|
+
const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
|
|
764
|
+
const listValue = targetBlock?.props?.[raw.listKey];
|
|
765
|
+
const frozen = resolveItemIdAtIndex(listValue, raw.afterIndex);
|
|
766
|
+
if (frozen)
|
|
767
|
+
raw.afterItemId = frozen;
|
|
768
|
+
}
|
|
769
|
+
// Declarative reorders: accept alias spellings of `order`, coerce
|
|
770
|
+
// digit-strings to numeric indexes for reorder_items, and freeze numeric
|
|
771
|
+
// entries to stable item ids against the current draft (same rationale and
|
|
772
|
+
// pageSlug gate as the item-op freeze above).
|
|
773
|
+
if (raw.op === "reorder_items" || raw.op === "reorder_blocks") {
|
|
774
|
+
if (!Array.isArray(raw.order)) {
|
|
775
|
+
const orderRaw = raw.new_order ?? raw.newOrder ?? raw.itemOrder ?? raw.item_order ?? raw.itemIds ?? raw.item_ids ??
|
|
776
|
+
raw.blockOrder ?? raw.block_order ?? raw.blockIds ?? raw.block_ids ?? raw.ids ?? raw.sequence;
|
|
777
|
+
if (Array.isArray(orderRaw))
|
|
778
|
+
raw.order = orderRaw;
|
|
779
|
+
}
|
|
780
|
+
if (raw.op === "reorder_items" && Array.isArray(raw.order)) {
|
|
781
|
+
raw.order = raw.order.map((entry) => typeof entry === "string" && /^\d+$/.test(entry.trim()) ? Number(entry.trim()) : entry);
|
|
782
|
+
if (typeof raw.blockId === "string" && typeof raw.listKey === "string" && freezeTargetsCurrentPage) {
|
|
783
|
+
const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
|
|
784
|
+
const listValue = targetBlock?.props?.[raw.listKey];
|
|
785
|
+
raw.order = raw.order.map((entry) => {
|
|
786
|
+
if (typeof entry !== "number")
|
|
787
|
+
return entry;
|
|
788
|
+
return resolveItemIdAtIndex(listValue, entry) ?? entry;
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (!raw.item) {
|
|
794
|
+
const sourceItem = raw.newItem ?? raw.new_item ?? raw.value;
|
|
795
|
+
if (sourceItem && typeof sourceItem === "object" && !Array.isArray(sourceItem))
|
|
796
|
+
raw.item = sourceItem;
|
|
797
|
+
}
|
|
798
|
+
if (raw.op === "add_item" && (!raw.item || typeof raw.item !== "object" || Array.isArray(raw.item))) {
|
|
799
|
+
const listKey = typeof raw.listKey === "string" ? raw.listKey.replace(/^\/+/, "") : "";
|
|
800
|
+
const blockId = typeof raw.blockId === "string" ? raw.blockId : "";
|
|
801
|
+
const currentBlock = blockId ? args?.currentPage?.blocks.find((block) => block.id === blockId) : undefined;
|
|
802
|
+
const currentProps = currentBlock?.props;
|
|
803
|
+
const listValue = listKey ? currentProps?.[listKey] : undefined;
|
|
804
|
+
const firstItem = Array.isArray(listValue) ? listValue.find((entry) => entry && typeof entry === "object" && !Array.isArray(entry)) : undefined;
|
|
805
|
+
if (firstItem) {
|
|
806
|
+
const cloned = structuredClone(firstItem);
|
|
807
|
+
delete cloned.id; // never reuse the template item's id — the engine mints a fresh one
|
|
808
|
+
raw.item = cloned;
|
|
809
|
+
}
|
|
810
|
+
else if (currentBlock?.type === "FAQAccordion") {
|
|
811
|
+
raw.item = { q: "New question", a: "New answer" };
|
|
812
|
+
}
|
|
813
|
+
else if (currentBlock?.type === "FeatureGrid") {
|
|
814
|
+
raw.item = { title: "New feature", description: "Feature description" };
|
|
815
|
+
}
|
|
816
|
+
else if (currentBlock?.type === "Testimonials") {
|
|
817
|
+
raw.item = { quote: "New testimonial", author: "Customer" };
|
|
818
|
+
}
|
|
819
|
+
else if (currentBlock?.type === "CardGrid") {
|
|
820
|
+
raw.item = { title: "New card", description: "Card description", ctaText: "Learn more", ctaHref: "/" };
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
if (!raw.newBlockId) {
|
|
824
|
+
raw.newBlockId = raw.new_block_id ?? raw.targetBlockId ?? raw.target_block_id ?? raw.copiedBlockId ?? raw.copied_block_id;
|
|
825
|
+
}
|
|
826
|
+
if (!raw.afterBlockId) {
|
|
827
|
+
raw.afterBlockId =
|
|
828
|
+
raw.after_block_id ?? raw.after ?? raw.insertAfterId ?? beforeToAfter(raw.beforeId ?? raw.insertBeforeId);
|
|
829
|
+
}
|
|
830
|
+
if (!raw.afterPageSlug) {
|
|
831
|
+
raw.afterPageSlug =
|
|
832
|
+
raw.afterPageSlug ??
|
|
833
|
+
raw.after_page_slug ??
|
|
834
|
+
raw.afterPage ??
|
|
835
|
+
raw.after_page ??
|
|
836
|
+
raw.anchorPageSlug ??
|
|
837
|
+
raw.anchor_page_slug ??
|
|
838
|
+
raw.after;
|
|
839
|
+
}
|
|
840
|
+
raw.afterPageSlug = resolvePageSlug(raw.afterPageSlug);
|
|
841
|
+
raw.beforePageSlug = resolvePageSlug(raw.beforePageSlug ?? raw.before_page_slug ?? raw.beforePage ?? raw.before_page);
|
|
842
|
+
if (!raw.patch) {
|
|
843
|
+
raw.patch = raw.props ?? raw.changes;
|
|
844
|
+
}
|
|
845
|
+
// LLMs sometimes put prop values directly on the op object instead of
|
|
846
|
+
// nesting them under "patch". Extract non-structural keys as the patch.
|
|
847
|
+
if (!raw.patch && raw.op === "update_props") {
|
|
848
|
+
const structuralKeys = new Set([
|
|
849
|
+
"op", "pageSlug", "page_slug", "blockId", "block_id", "patch", "props", "changes",
|
|
850
|
+
"type", "blockType", "block_type", "newBlockType", "new_block_type", "targetBlockType", "target_block_type",
|
|
851
|
+
"newPageSlug", "new_page_slug", "targetSlug", "target_slug", "toPageSlug", "to_page_slug",
|
|
852
|
+
"afterBlockId", "after_block_id", "afterPageSlug", "after_page_slug", "beforePageSlug", "before_page_slug",
|
|
853
|
+
"newBlockId", "new_block_id", "listKey", "list_key", "block",
|
|
854
|
+
"path", "index", "item", "afterIndex", "itemPath",
|
|
855
|
+
"itemId", "item_id", "afterItemId", "after_item_id", "order",
|
|
856
|
+
]);
|
|
857
|
+
const extracted = {};
|
|
858
|
+
let found = false;
|
|
859
|
+
for (const key of Object.keys(raw)) {
|
|
860
|
+
if (!structuralKeys.has(key)) {
|
|
861
|
+
extracted[key] = raw[key];
|
|
862
|
+
found = true;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (found)
|
|
866
|
+
raw.patch = extracted;
|
|
867
|
+
}
|
|
868
|
+
// Remap update_props patch keys: heading→title for non-Hero, question→q/answer→a in list items
|
|
869
|
+
if (raw.op === "update_props" && raw.patch && typeof raw.patch === "object" && !Array.isArray(raw.patch) && typeof raw.blockId === "string") {
|
|
870
|
+
const patch = raw.patch;
|
|
871
|
+
const targetBlock = args?.currentPage?.blocks.find((b) => b.id === raw.blockId);
|
|
872
|
+
const blockType = targetBlock?.type ?? "";
|
|
873
|
+
if (blockType !== "Hero" && "heading" in patch && !("title" in patch)) {
|
|
874
|
+
patch.title = patch.heading;
|
|
875
|
+
delete patch.heading;
|
|
876
|
+
}
|
|
877
|
+
const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
|
|
878
|
+
for (const [propKey, propVal] of Object.entries(patch)) {
|
|
879
|
+
if (!Array.isArray(propVal))
|
|
880
|
+
continue;
|
|
881
|
+
patch[propKey] = propVal.map((item) => {
|
|
882
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
883
|
+
return item;
|
|
884
|
+
const entry = item;
|
|
885
|
+
let changed = false;
|
|
886
|
+
const mapped = {};
|
|
887
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
888
|
+
const alias = itemKeyAliases[k.toLowerCase()];
|
|
889
|
+
if (alias && !(alias in entry)) {
|
|
890
|
+
mapped[alias] = v;
|
|
891
|
+
changed = true;
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
mapped[k] = v;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return changed ? mapped : entry;
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
// Block-specific prop key remapping and type coercion (mirrors add_block path)
|
|
901
|
+
for (const k of ["autoplay", "loop", "striped"]) {
|
|
902
|
+
if (typeof patch[k] === "boolean")
|
|
903
|
+
patch[k] = patch[k] ? "true" : "false";
|
|
904
|
+
}
|
|
905
|
+
if (blockType === "Carousel" && Array.isArray(patch.slides) && !patch.items) {
|
|
906
|
+
patch.items = patch.slides;
|
|
907
|
+
delete patch.slides;
|
|
908
|
+
}
|
|
909
|
+
if (blockType === "Gallery") {
|
|
910
|
+
const alt = patch.photos ?? patch.pictures;
|
|
911
|
+
if (Array.isArray(alt) && !patch.images) {
|
|
912
|
+
patch.images = alt;
|
|
913
|
+
delete patch.photos;
|
|
914
|
+
delete patch.pictures;
|
|
915
|
+
}
|
|
916
|
+
if (typeof patch.columns === "number")
|
|
917
|
+
patch.columns = String(patch.columns);
|
|
918
|
+
}
|
|
919
|
+
if (blockType === "Table") {
|
|
920
|
+
if (Array.isArray(patch.columns) && !patch.headers) {
|
|
921
|
+
patch.headers = patch.columns;
|
|
922
|
+
delete patch.columns;
|
|
923
|
+
}
|
|
924
|
+
if (Array.isArray(patch.data) && !patch.rows) {
|
|
925
|
+
patch.rows = patch.data;
|
|
926
|
+
delete patch.data;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// Convert update_props with appended array items → add_item ops.
|
|
931
|
+
// When the LLM returns the full array (existing + new), extract only the new items as add_item ops
|
|
932
|
+
// to avoid overwriting existing items with potentially stale copies.
|
|
933
|
+
if (raw.op === "update_props" && typeof raw.blockId === "string" && raw.patch && typeof raw.patch === "object" && !Array.isArray(raw.patch) && args?.currentPage) {
|
|
934
|
+
const patch = raw.patch;
|
|
935
|
+
const targetBlock = args.currentPage.blocks.find((b) => b.id === raw.blockId);
|
|
936
|
+
if (targetBlock) {
|
|
937
|
+
const blockProps = targetBlock.props;
|
|
938
|
+
for (const [key, patchVal] of Object.entries(patch)) {
|
|
939
|
+
if (!Array.isArray(patchVal))
|
|
940
|
+
continue;
|
|
941
|
+
const existing = blockProps[key];
|
|
942
|
+
if (!Array.isArray(existing) || existing.length === 0)
|
|
943
|
+
continue;
|
|
944
|
+
if (patchVal.length <= existing.length)
|
|
945
|
+
continue;
|
|
946
|
+
// The patch array is longer — likely existing items + new appended items.
|
|
947
|
+
// Convert new tail items to add_item ops.
|
|
948
|
+
const newItems = patchVal.slice(existing.length);
|
|
949
|
+
if (newItems.length > 0 && newItems.every((it) => it && typeof it === "object" && !Array.isArray(it))) {
|
|
950
|
+
delete patch[key];
|
|
951
|
+
const slug = raw.pageSlug ?? args.defaultSlug ?? "/";
|
|
952
|
+
const addItemOps = newItems.map((item) => ({
|
|
953
|
+
op: "add_item",
|
|
954
|
+
pageSlug: slug,
|
|
955
|
+
blockId: raw.blockId,
|
|
956
|
+
listKey: key,
|
|
957
|
+
item
|
|
958
|
+
}));
|
|
959
|
+
// If patch has no remaining keys, replace this op entirely with add_item ops
|
|
960
|
+
if (Object.keys(patch).length === 0) {
|
|
961
|
+
return addItemOps;
|
|
962
|
+
}
|
|
963
|
+
// Otherwise keep the update_props for scalar changes and append add_item ops
|
|
964
|
+
return [raw, ...addItemOps];
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (raw.op === "update_item" && typeof raw.blockId === "string" && typeof raw.listKey === "string") {
|
|
970
|
+
const block = args?.currentPage?.blocks.find((candidate) => candidate.id === raw.blockId);
|
|
971
|
+
const blockProps = block?.props;
|
|
972
|
+
const list = blockProps?.[raw.listKey];
|
|
973
|
+
if (Array.isArray(list)) {
|
|
974
|
+
const index = typeof raw.index === "number" ? raw.index : -1;
|
|
975
|
+
const itemAtIndex = index >= 0 ? list[index] : undefined;
|
|
976
|
+
const referenceItem = itemAtIndex && typeof itemAtIndex === "object" && !Array.isArray(itemAtIndex)
|
|
977
|
+
? itemAtIndex
|
|
978
|
+
: list.find((entry) => entry && typeof entry === "object" && !Array.isArray(entry));
|
|
979
|
+
const allowedKeys = referenceItem ? Object.keys(referenceItem) : [];
|
|
980
|
+
const remapped = remapListItemPatchKeys({ patchRaw: raw.patch, allowedKeys });
|
|
981
|
+
if (remapped)
|
|
982
|
+
raw.patch = remapped;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (raw.op === "update_props" &&
|
|
986
|
+
(!raw.blockId || typeof raw.blockId !== "string") &&
|
|
987
|
+
args?.defaultSlug) {
|
|
988
|
+
const patch = patchObject(raw.patch);
|
|
989
|
+
const newSlugFromPatch = normalizeRouteCandidate(patch?.slug ?? patch?.path ?? patch?.route);
|
|
990
|
+
const newSlugFromPath = typeof raw.path === "string" && raw.path.startsWith("/") ? normalizeRouteCandidate(raw.path) : null;
|
|
991
|
+
const fromSlugFromMentions = routeMentions[0];
|
|
992
|
+
const toSlugFromMentions = routeMentions.length >= 2 ? routeMentions[routeMentions.length - 1] : undefined;
|
|
993
|
+
const nextSlug = raw.newPageSlug ?? newSlugFromPatch ?? newSlugFromPath ?? toSlugFromMentions;
|
|
994
|
+
const fromSlug = resolvePageSlug(raw.pageSlug ?? raw.fromPageSlug ?? raw.from_page_slug ?? raw.oldSlug ?? fromSlugFromMentions);
|
|
995
|
+
if (fromSlug && nextSlug && fromSlug !== nextSlug) {
|
|
996
|
+
raw.op = "rename_page";
|
|
997
|
+
raw.pageSlug = fromSlug;
|
|
998
|
+
raw.newPageSlug = nextSlug;
|
|
999
|
+
delete raw.patch;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if (raw.op === "remove_block" && (!raw.blockId || typeof raw.blockId !== "string")) {
|
|
1003
|
+
const asksDeletePage = /\b(delete|remove)\b.*\bpage\b/.test(userMessage);
|
|
1004
|
+
if (asksDeletePage) {
|
|
1005
|
+
raw.op = "remove_page";
|
|
1006
|
+
raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? raw.route ?? routeMentions[0] ?? args?.defaultSlug);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
// Convert remove_block → remove_item when user is removing a list item (e.g., "remove the first question")
|
|
1010
|
+
if (raw.op === "remove_block" && typeof raw.blockId === "string" && args?.currentPage) {
|
|
1011
|
+
const asksRemoveItem = /\b(question|item|faq|entry|testimonial|card|feature|first|second|third|last)\b/i.test(userMessage);
|
|
1012
|
+
if (asksRemoveItem) {
|
|
1013
|
+
const block = args.currentPage.blocks.find((b) => b.id === raw.blockId);
|
|
1014
|
+
if (block) {
|
|
1015
|
+
const bProps = block.props;
|
|
1016
|
+
if (bProps) {
|
|
1017
|
+
for (const [key, val] of Object.entries(bProps)) {
|
|
1018
|
+
if (!Array.isArray(val) || val.length === 0)
|
|
1019
|
+
continue;
|
|
1020
|
+
// Determine which index to remove
|
|
1021
|
+
let idx = 0;
|
|
1022
|
+
if (/\blast\b/i.test(userMessage))
|
|
1023
|
+
idx = val.length - 1;
|
|
1024
|
+
else if (/\bsecond\b/i.test(userMessage))
|
|
1025
|
+
idx = 1;
|
|
1026
|
+
else if (/\bthird\b/i.test(userMessage))
|
|
1027
|
+
idx = 2;
|
|
1028
|
+
raw.op = "remove_item";
|
|
1029
|
+
raw.listKey = key;
|
|
1030
|
+
raw.index = idx;
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (raw.op === "rename_page") {
|
|
1038
|
+
const nextSlug = raw.newPageSlug ??
|
|
1039
|
+
normalizeRouteCandidate(raw.path) ??
|
|
1040
|
+
normalizeRouteCandidate(raw.route) ??
|
|
1041
|
+
normalizeRouteCandidate(raw.slug) ??
|
|
1042
|
+
(routeMentions.length >= 2 ? routeMentions[routeMentions.length - 1] : undefined);
|
|
1043
|
+
raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.fromPageSlug ?? raw.from_page_slug ?? raw.oldSlug ?? routeMentions[0]);
|
|
1044
|
+
raw.newPageSlug = nextSlug;
|
|
1045
|
+
if (!raw.newTitle && typeof raw.title === "string" && raw.title.trim().length > 0)
|
|
1046
|
+
raw.newTitle = raw.title.trim();
|
|
1047
|
+
if (typeof raw.pageSlug === "string" &&
|
|
1048
|
+
typeof raw.newPageSlug === "string" &&
|
|
1049
|
+
raw.pageSlug === raw.newPageSlug) {
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
if (raw.op === "remove_page") {
|
|
1054
|
+
raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? raw.route ?? routeMentions[0] ?? args?.defaultSlug);
|
|
1055
|
+
}
|
|
1056
|
+
if (raw.op === "move_page") {
|
|
1057
|
+
raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? raw.route ?? routeMentions[0] ?? args?.defaultSlug);
|
|
1058
|
+
if (!raw.afterPageSlug && raw.beforePageSlug && args?.currentPage) {
|
|
1059
|
+
if (raw.beforePageSlug === "/")
|
|
1060
|
+
raw.afterPageSlug = undefined;
|
|
1061
|
+
else if (raw.beforePageSlug === args.currentPage.slug)
|
|
1062
|
+
raw.afterPageSlug = undefined;
|
|
1063
|
+
}
|
|
1064
|
+
if (!raw.afterPageSlug && routeMentions.length >= 2) {
|
|
1065
|
+
const lower = userMessage;
|
|
1066
|
+
if (/\b(after|below|under)\b/.test(lower))
|
|
1067
|
+
raw.afterPageSlug = routeMentions[1];
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (raw.op === "duplicate_page") {
|
|
1071
|
+
raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? raw.route ?? raw.from ?? routeMentions[0] ?? args?.defaultSlug);
|
|
1072
|
+
raw.newPageSlug = normalizeRouteCandidate(raw.newPageSlug ?? raw.new_page_slug ?? raw.targetSlug ?? raw.target_slug ?? raw.toPageSlug ?? raw.to_page_slug ?? raw.to);
|
|
1073
|
+
if (!raw.afterPageSlug && routeMentions.length >= 2) {
|
|
1074
|
+
const lower = userMessage;
|
|
1075
|
+
if (/\b(after|below|under)\b/.test(lower))
|
|
1076
|
+
raw.afterPageSlug = routeMentions[1];
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (raw.op === "duplicate_block") {
|
|
1080
|
+
raw.toPageSlug = resolvePageSlug(raw.toPageSlug ?? raw.to_page_slug ?? raw.targetPageSlug ?? raw.target_page_slug ?? raw.newPageSlug ?? raw.new_page_slug);
|
|
1081
|
+
}
|
|
1082
|
+
if (!raw.block) {
|
|
1083
|
+
raw.block = raw.newBlock ?? raw.new_block;
|
|
1084
|
+
if (!raw.block && (raw.op === "add_block" || raw.op === "create_page") && normalizedType) {
|
|
1085
|
+
let generatedId = typeof raw.blockId === "string" && raw.blockId.length > 0
|
|
1086
|
+
? raw.blockId
|
|
1087
|
+
: `b_${String(normalizedType).toLowerCase()}_${Date.now()}`;
|
|
1088
|
+
// Dedup against both existing page blocks and earlier ops in this batch
|
|
1089
|
+
let suffix = 0;
|
|
1090
|
+
while (usedBlockIds.has(generatedId)) {
|
|
1091
|
+
suffix++;
|
|
1092
|
+
generatedId = `b_${String(normalizedType).toLowerCase()}_${Date.now()}_${suffix}`;
|
|
1093
|
+
}
|
|
1094
|
+
usedBlockIds.add(generatedId);
|
|
1095
|
+
const incomingPatch = patchObject(raw.props ?? raw.patch ?? raw.changes) ?? {};
|
|
1096
|
+
raw.block = {
|
|
1097
|
+
id: generatedId,
|
|
1098
|
+
type: normalizedType,
|
|
1099
|
+
props: { ...defaultPropsForType(normalizedType), ...incomingPatch }
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
if (raw.op === "add_block" && raw.block && typeof raw.block === "object" && !Array.isArray(raw.block)) {
|
|
1104
|
+
const block = raw.block;
|
|
1105
|
+
if (typeof block.type === "string") {
|
|
1106
|
+
const fixed = allowedBlockTypes.find((t) => t.toLowerCase() === block.type.toLowerCase()) ?? inferBlockTypeFromText(block.type);
|
|
1107
|
+
if (fixed)
|
|
1108
|
+
block.type = fixed;
|
|
1109
|
+
}
|
|
1110
|
+
else if (normalizedType) {
|
|
1111
|
+
block.type = normalizedType;
|
|
1112
|
+
}
|
|
1113
|
+
if (!block.props || typeof block.props !== "object" || Array.isArray(block.props)) {
|
|
1114
|
+
if (raw.patch || raw.props || raw.changes) {
|
|
1115
|
+
block.props = patchObject(raw.patch ?? raw.props ?? raw.changes) ?? {};
|
|
1116
|
+
}
|
|
1117
|
+
else if (typeof block.type === "string") {
|
|
1118
|
+
// LLM omitted props entirely — seed with schema defaults so validation passes
|
|
1119
|
+
block.props = defaultPropsForType(block.type);
|
|
1120
|
+
}
|
|
1121
|
+
else {
|
|
1122
|
+
block.props = {};
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if ((!block.id || typeof block.id !== "string") && typeof block.type === "string") {
|
|
1126
|
+
let fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}`;
|
|
1127
|
+
let sfx = 0;
|
|
1128
|
+
while (usedBlockIds.has(fallbackId)) {
|
|
1129
|
+
sfx++;
|
|
1130
|
+
fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}_${sfx}`;
|
|
1131
|
+
}
|
|
1132
|
+
usedBlockIds.add(fallbackId);
|
|
1133
|
+
block.id = fallbackId;
|
|
1134
|
+
}
|
|
1135
|
+
raw.block = block;
|
|
1136
|
+
// Remap heading→title for non-Hero blocks (LLMs often confuse heading/title)
|
|
1137
|
+
if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
|
|
1138
|
+
const bProps = block.props;
|
|
1139
|
+
const blockType = typeof block.type === "string" ? block.type : "";
|
|
1140
|
+
if (blockType !== "Hero" && "heading" in bProps && !("title" in bProps)) {
|
|
1141
|
+
bProps.title = bProps.heading;
|
|
1142
|
+
delete bProps.heading;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
// Remap list item keys inside add_block props (e.g., question→q, answer→a)
|
|
1146
|
+
if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
|
|
1147
|
+
const bProps = block.props;
|
|
1148
|
+
const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
|
|
1149
|
+
for (const [propKey, propVal] of Object.entries(bProps)) {
|
|
1150
|
+
if (!Array.isArray(propVal))
|
|
1151
|
+
continue;
|
|
1152
|
+
bProps[propKey] = propVal.map((item) => {
|
|
1153
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
1154
|
+
return item;
|
|
1155
|
+
const entry = item;
|
|
1156
|
+
let changed = false;
|
|
1157
|
+
const mapped = {};
|
|
1158
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
1159
|
+
const alias = itemKeyAliases[k.toLowerCase()];
|
|
1160
|
+
if (alias && !(alias in entry)) {
|
|
1161
|
+
mapped[alias] = v;
|
|
1162
|
+
changed = true;
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
mapped[k] = v;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
return changed ? mapped : entry;
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
// Block-specific prop key remapping and type coercion
|
|
1173
|
+
if (typeof block.type === "string" && block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
|
|
1174
|
+
const cProps = block.props;
|
|
1175
|
+
const bt = block.type;
|
|
1176
|
+
// Coerce boolean→string for "true"/"false" enum props (Carousel, Video, Table)
|
|
1177
|
+
for (const k of ["autoplay", "loop", "striped"]) {
|
|
1178
|
+
if (typeof cProps[k] === "boolean")
|
|
1179
|
+
cProps[k] = cProps[k] ? "true" : "false";
|
|
1180
|
+
}
|
|
1181
|
+
// Carousel: slides→items
|
|
1182
|
+
if (bt === "Carousel" && Array.isArray(cProps.slides) && !cProps.items) {
|
|
1183
|
+
cProps.items = cProps.slides;
|
|
1184
|
+
delete cProps.slides;
|
|
1185
|
+
}
|
|
1186
|
+
// Gallery: photos/pictures→images, numeric columns→string
|
|
1187
|
+
if (bt === "Gallery") {
|
|
1188
|
+
const alt = cProps.photos ?? cProps.pictures;
|
|
1189
|
+
if (Array.isArray(alt) && !cProps.images) {
|
|
1190
|
+
cProps.images = alt;
|
|
1191
|
+
delete cProps.photos;
|
|
1192
|
+
delete cProps.pictures;
|
|
1193
|
+
}
|
|
1194
|
+
if (typeof cProps.columns === "number")
|
|
1195
|
+
cProps.columns = String(cProps.columns);
|
|
1196
|
+
}
|
|
1197
|
+
// Table: columns→headers, data→rows
|
|
1198
|
+
if (bt === "Table") {
|
|
1199
|
+
if (Array.isArray(cProps.columns) && !cProps.headers) {
|
|
1200
|
+
cProps.headers = cProps.columns;
|
|
1201
|
+
delete cProps.columns;
|
|
1202
|
+
}
|
|
1203
|
+
if (Array.isArray(cProps.data) && !cProps.rows) {
|
|
1204
|
+
cProps.rows = cProps.data;
|
|
1205
|
+
delete cProps.data;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// Convert add_block → add_item when targeting an existing single-instance block with list items
|
|
1211
|
+
if (raw.op === "add_block" && raw.block && typeof raw.block === "object" && !Array.isArray(raw.block) && args?.currentPage) {
|
|
1212
|
+
const block = raw.block;
|
|
1213
|
+
const blockType = typeof block.type === "string" ? block.type : undefined;
|
|
1214
|
+
if (blockType) {
|
|
1215
|
+
const existingBlocks = args.currentPage.blocks.filter((b) => b.type === blockType);
|
|
1216
|
+
if (existingBlocks.length === 1) {
|
|
1217
|
+
const existing = existingBlocks[0];
|
|
1218
|
+
const existingProps = existing.props;
|
|
1219
|
+
const newProps = block.props;
|
|
1220
|
+
if (existingProps && newProps) {
|
|
1221
|
+
for (const [key, val] of Object.entries(existingProps)) {
|
|
1222
|
+
if (!Array.isArray(val))
|
|
1223
|
+
continue;
|
|
1224
|
+
const newVal = newProps[key];
|
|
1225
|
+
if (!Array.isArray(newVal) || newVal.length === 0)
|
|
1226
|
+
continue;
|
|
1227
|
+
const newItem = newVal[0];
|
|
1228
|
+
if (newItem && typeof newItem === "object" && !Array.isArray(newItem)) {
|
|
1229
|
+
raw.op = "add_item";
|
|
1230
|
+
raw.blockId = existing.id;
|
|
1231
|
+
raw.listKey = key;
|
|
1232
|
+
raw.item = newItem;
|
|
1233
|
+
raw.pageSlug = raw.pageSlug ?? args?.defaultSlug;
|
|
1234
|
+
delete raw.block;
|
|
1235
|
+
break;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
const createSlugCandidate = resolvePageSlug(raw.pageSlug ?? raw.path ?? raw.slug ?? requestedRoute);
|
|
1243
|
+
const explicitCreateTarget = createSlugCandidate && createSlugCandidate !== args?.defaultSlug;
|
|
1244
|
+
// If user asked to create a page and model emitted add_block on a new route, synthesize create_page.
|
|
1245
|
+
if (raw.op === "add_block" && createPageIntent && explicitCreateTarget && !createdPageSlug) {
|
|
1246
|
+
const createSlug = createSlugCandidate ?? requestedRoute ?? args?.defaultSlug ?? "/";
|
|
1247
|
+
const nowIso = new Date().toISOString();
|
|
1248
|
+
let firstBlock = null;
|
|
1249
|
+
if (raw.block && typeof raw.block === "object" && !Array.isArray(raw.block)) {
|
|
1250
|
+
const block = raw.block;
|
|
1251
|
+
const typeRaw = typeof block.type === "string" ? block.type : normalizedType;
|
|
1252
|
+
const blockType = typeof typeRaw === "string"
|
|
1253
|
+
? allowedBlockTypes.find((t) => t.toLowerCase() === typeRaw.toLowerCase()) ?? inferBlockTypeFromText(typeRaw)
|
|
1254
|
+
: undefined;
|
|
1255
|
+
if (blockType) {
|
|
1256
|
+
const id = typeof block.id === "string" && block.id.length > 0 ? block.id : `b_${blockType.toLowerCase()}_${Date.now()}`;
|
|
1257
|
+
const props = block.props && typeof block.props === "object" && !Array.isArray(block.props)
|
|
1258
|
+
? { ...defaultPropsForType(blockType), ...block.props }
|
|
1259
|
+
: defaultPropsForType(blockType);
|
|
1260
|
+
firstBlock = { id, type: blockType, props };
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (!firstBlock) {
|
|
1264
|
+
firstBlock = {
|
|
1265
|
+
id: `b_hero_${Date.now()}`,
|
|
1266
|
+
type: "Hero",
|
|
1267
|
+
props: defaultPropsForType("Hero")
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
raw.op = "create_page";
|
|
1271
|
+
raw.page = {
|
|
1272
|
+
id: pageIdFromSlug(createSlug),
|
|
1273
|
+
slug: createSlug,
|
|
1274
|
+
title: pageTitleFromSlug(createSlug),
|
|
1275
|
+
updatedAt: nowIso,
|
|
1276
|
+
blocks: [firstBlock]
|
|
1277
|
+
};
|
|
1278
|
+
raw.pageSlug = createSlug;
|
|
1279
|
+
createdPageSlug = createSlug;
|
|
1280
|
+
return raw;
|
|
1281
|
+
}
|
|
1282
|
+
// LLMs sometimes emit create_page when they actually mean add_block.
|
|
1283
|
+
if (raw.op === "create_page" &&
|
|
1284
|
+
!raw.page &&
|
|
1285
|
+
!explicitCreateTarget &&
|
|
1286
|
+
(raw.block || normalizedType || raw.blockId || raw.patch || raw.props)) {
|
|
1287
|
+
raw.op = "add_block";
|
|
1288
|
+
raw.pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? args?.defaultSlug);
|
|
1289
|
+
}
|
|
1290
|
+
// LLMs also emit create_page with blocks[] for existing pages. Convert to add_block sequence.
|
|
1291
|
+
// But only when targeting the current page — if it's an explicit new route, let it fall
|
|
1292
|
+
// through to the create_page handler that synthesizes a proper PageDoc.
|
|
1293
|
+
if (raw.op === "create_page" &&
|
|
1294
|
+
!raw.page &&
|
|
1295
|
+
!explicitCreateTarget &&
|
|
1296
|
+
Array.isArray(raw.blocks) &&
|
|
1297
|
+
raw.blocks.length > 0) {
|
|
1298
|
+
const pageSlug = resolvePageSlug(raw.pageSlug ?? raw.path ?? args?.defaultSlug) ?? args?.defaultSlug;
|
|
1299
|
+
if (!pageSlug)
|
|
1300
|
+
return raw;
|
|
1301
|
+
const out = [];
|
|
1302
|
+
let previousId;
|
|
1303
|
+
// Track blocks as we add them so nextBlockId generates unique IDs
|
|
1304
|
+
let pageSnapshot = args?.currentPage;
|
|
1305
|
+
for (const candidate of raw.blocks) {
|
|
1306
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
1307
|
+
continue;
|
|
1308
|
+
const block = { ...candidate };
|
|
1309
|
+
const typeRaw = typeof block.type === "string" ? block.type : "";
|
|
1310
|
+
const blockType = allowedBlockTypes.find((t) => t.toLowerCase() === typeRaw.toLowerCase()) ?? inferBlockTypeFromText(typeRaw);
|
|
1311
|
+
if (!blockType)
|
|
1312
|
+
continue;
|
|
1313
|
+
if (typeof block.id !== "string" || block.id.length === 0) {
|
|
1314
|
+
block.id = pageSnapshot ? nextBlockId(blockType, pageSnapshot) : `b_${blockType.toLowerCase()}_${Date.now()}`;
|
|
1315
|
+
}
|
|
1316
|
+
if (!block.props || typeof block.props !== "object" || Array.isArray(block.props)) {
|
|
1317
|
+
block.props = defaultPropsForType(blockType);
|
|
1318
|
+
}
|
|
1319
|
+
const addOp = {
|
|
1320
|
+
op: "add_block",
|
|
1321
|
+
pageSlug,
|
|
1322
|
+
block
|
|
1323
|
+
};
|
|
1324
|
+
if (previousId)
|
|
1325
|
+
addOp.afterBlockId = previousId;
|
|
1326
|
+
previousId = block.id;
|
|
1327
|
+
// Update snapshot so the next nextBlockId sees this block's ID
|
|
1328
|
+
if (pageSnapshot) {
|
|
1329
|
+
pageSnapshot = { ...pageSnapshot, blocks: [...pageSnapshot.blocks, { id: block.id, type: blockType, props: block.props }] };
|
|
1330
|
+
}
|
|
1331
|
+
out.push(addOp);
|
|
1332
|
+
}
|
|
1333
|
+
return out.length > 0 ? out : raw;
|
|
1334
|
+
}
|
|
1335
|
+
// Accept lightweight or partial create_page operations and synthesize a valid PageDoc payload.
|
|
1336
|
+
if (raw.op === "create_page") {
|
|
1337
|
+
const pageInput = raw.page && typeof raw.page === "object" && !Array.isArray(raw.page) ? raw.page : {};
|
|
1338
|
+
const pageSlugInput = pageInput.slug ?? raw.pageSlug ?? raw.page_slug ?? raw.path ?? raw.slug ?? raw.route ?? requestedRoute ?? args?.defaultSlug ?? "/";
|
|
1339
|
+
const slug = resolvePageSlug(pageSlugInput) ?? requestedRoute ?? args?.defaultSlug ?? "/";
|
|
1340
|
+
const nowIso = new Date().toISOString();
|
|
1341
|
+
const blocks = [];
|
|
1342
|
+
const shouldTreatAsCurrentPageEdit = !requestedCreateSlug && refersToCurrentPage && !!args?.defaultSlug && slug === args.defaultSlug;
|
|
1343
|
+
// Blocks may be inside pageInput.blocks (page wrapper) or raw.blocks (flat format).
|
|
1344
|
+
const blocksSource = Array.isArray(pageInput.blocks) ? pageInput.blocks : Array.isArray(raw.blocks) ? raw.blocks : null;
|
|
1345
|
+
if (blocksSource) {
|
|
1346
|
+
const usedIds = new Set();
|
|
1347
|
+
for (const candidate of blocksSource) {
|
|
1348
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate))
|
|
1349
|
+
continue;
|
|
1350
|
+
const block = candidate;
|
|
1351
|
+
const typeRaw = typeof block.type === "string" ? block.type : "";
|
|
1352
|
+
const blockType = allowedBlockTypes.find((t) => t.toLowerCase() === typeRaw.toLowerCase()) ?? inferBlockTypeFromText(typeRaw);
|
|
1353
|
+
if (!blockType)
|
|
1354
|
+
continue;
|
|
1355
|
+
let id = typeof block.id === "string" && block.id.length > 0 ? block.id : `b_${blockType.toLowerCase()}_${Date.now()}`;
|
|
1356
|
+
let suffix = 1;
|
|
1357
|
+
while (usedIds.has(id)) {
|
|
1358
|
+
id = `b_${blockType.toLowerCase()}_${Date.now()}_${suffix++}`;
|
|
1359
|
+
}
|
|
1360
|
+
usedIds.add(id);
|
|
1361
|
+
const props = block.props && typeof block.props === "object" && !Array.isArray(block.props)
|
|
1362
|
+
? { ...defaultPropsForType(blockType), ...block.props }
|
|
1363
|
+
: defaultPropsForType(blockType);
|
|
1364
|
+
blocks.push({ id, type: blockType, props });
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (shouldTreatAsCurrentPageEdit && blocks.length > 0) {
|
|
1368
|
+
let previousId;
|
|
1369
|
+
const out = [];
|
|
1370
|
+
for (const block of blocks) {
|
|
1371
|
+
const addOp = { op: "add_block", pageSlug: slug, block };
|
|
1372
|
+
if (previousId)
|
|
1373
|
+
addOp.afterBlockId = previousId;
|
|
1374
|
+
previousId = block.id;
|
|
1375
|
+
out.push(addOp);
|
|
1376
|
+
}
|
|
1377
|
+
return out;
|
|
1378
|
+
}
|
|
1379
|
+
if (blocks.length === 0 && raw.block && typeof raw.block === "object" && !Array.isArray(raw.block)) {
|
|
1380
|
+
const block = { ...raw.block };
|
|
1381
|
+
const typeRaw = typeof block.type === "string" ? block.type : normalizedType;
|
|
1382
|
+
const blockType = typeof typeRaw === "string"
|
|
1383
|
+
? allowedBlockTypes.find((t) => t.toLowerCase() === typeRaw.toLowerCase()) ?? inferBlockTypeFromText(typeRaw)
|
|
1384
|
+
: undefined;
|
|
1385
|
+
if (blockType) {
|
|
1386
|
+
const id = typeof block.id === "string" && block.id.length > 0 ? block.id : `b_${blockType.toLowerCase()}_${Date.now()}`;
|
|
1387
|
+
const props = block.props && typeof block.props === "object" && !Array.isArray(block.props)
|
|
1388
|
+
? { ...defaultPropsForType(blockType), ...block.props }
|
|
1389
|
+
: defaultPropsForType(blockType);
|
|
1390
|
+
blocks.push({ id, type: blockType, props });
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (shouldTreatAsCurrentPageEdit && blocks.length > 0) {
|
|
1394
|
+
raw.op = "add_block";
|
|
1395
|
+
raw.pageSlug = slug;
|
|
1396
|
+
raw.block = blocks[0];
|
|
1397
|
+
delete raw.page;
|
|
1398
|
+
delete raw.page_slug;
|
|
1399
|
+
delete raw.slug;
|
|
1400
|
+
delete raw.path;
|
|
1401
|
+
return raw;
|
|
1402
|
+
}
|
|
1403
|
+
raw.page = {
|
|
1404
|
+
id: typeof pageInput.id === "string" && pageInput.id.trim().length > 0 ? pageInput.id.trim() : pageIdFromSlug(slug),
|
|
1405
|
+
slug,
|
|
1406
|
+
title: typeof pageInput.title === "string" && pageInput.title.trim().length > 0 ? pageInput.title.trim() : pageTitleFromSlug(slug),
|
|
1407
|
+
updatedAt: typeof pageInput.updatedAt === "string" && pageInput.updatedAt.trim().length > 0 ? pageInput.updatedAt.trim() : nowIso,
|
|
1408
|
+
blocks
|
|
1409
|
+
};
|
|
1410
|
+
raw.pageSlug = slug;
|
|
1411
|
+
createdPageSlug = slug;
|
|
1412
|
+
}
|
|
1413
|
+
// If model mixes create_page + add_block and keeps add_block on the current route, move it to the new route.
|
|
1414
|
+
if (raw.op === "add_block" && createPageIntent && createdPageSlug && raw.pageSlug === args?.defaultSlug) {
|
|
1415
|
+
raw.pageSlug = createdPageSlug;
|
|
1416
|
+
}
|
|
1417
|
+
// Intent repair: if user asked for bottom/end and model omitted an anchor, place at end.
|
|
1418
|
+
if ((raw.op === "move_block" || raw.op === "add_block") &&
|
|
1419
|
+
!raw.afterBlockId &&
|
|
1420
|
+
args?.currentPage &&
|
|
1421
|
+
(userMessage.includes("bottom") || userMessage.includes("end") || userMessage.includes("last"))) {
|
|
1422
|
+
const movingId = typeof raw.blockId === "string"
|
|
1423
|
+
? raw.blockId
|
|
1424
|
+
: raw.op === "add_block" && raw.block && typeof raw.block === "object" && typeof raw.block.id === "string"
|
|
1425
|
+
? raw.block.id
|
|
1426
|
+
: undefined;
|
|
1427
|
+
const tail = [...args.currentPage.blocks].reverse().find((b) => b.id !== movingId);
|
|
1428
|
+
if (tail)
|
|
1429
|
+
raw.afterBlockId = tail.id;
|
|
1430
|
+
}
|
|
1431
|
+
// Remap add_item item keys to match existing schema (e.g., question→q, answer→a)
|
|
1432
|
+
if (raw.op === "add_item" && raw.item && typeof raw.item === "object" && !Array.isArray(raw.item) && typeof raw.blockId === "string") {
|
|
1433
|
+
const targetBlock = args?.currentPage?.blocks.find((b) => b.id === raw.blockId);
|
|
1434
|
+
if (targetBlock) {
|
|
1435
|
+
const bProps = targetBlock.props;
|
|
1436
|
+
const lk = typeof raw.listKey === "string" ? raw.listKey : undefined;
|
|
1437
|
+
const list = lk ? bProps?.[lk] : undefined;
|
|
1438
|
+
const refItem = Array.isArray(list)
|
|
1439
|
+
? list.find((e) => e && typeof e === "object" && !Array.isArray(e))
|
|
1440
|
+
: undefined;
|
|
1441
|
+
if (refItem) {
|
|
1442
|
+
const allowedKeys = Object.keys(refItem);
|
|
1443
|
+
const remapped = remapListItemPatchKeys({ patchRaw: raw.item, allowedKeys });
|
|
1444
|
+
if (remapped) {
|
|
1445
|
+
// Semantic aliases for common key alternatives
|
|
1446
|
+
const extraAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
|
|
1447
|
+
const allowedSet = new Set(allowedKeys);
|
|
1448
|
+
const final = {};
|
|
1449
|
+
for (const [k, v] of Object.entries(remapped)) {
|
|
1450
|
+
const alias = extraAliases[k.toLowerCase()];
|
|
1451
|
+
if (alias && allowedSet.has(alias) && !(alias in remapped)) {
|
|
1452
|
+
final[alias] = v;
|
|
1453
|
+
}
|
|
1454
|
+
else {
|
|
1455
|
+
final[k] = v;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
raw.item = final;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
return raw;
|
|
1464
|
+
});
|
|
1465
|
+
const sanitizedOps = normalizedOps.filter((item) => {
|
|
1466
|
+
if (!item)
|
|
1467
|
+
return false;
|
|
1468
|
+
if (typeof item !== "object" || Array.isArray(item))
|
|
1469
|
+
return true;
|
|
1470
|
+
const raw = item;
|
|
1471
|
+
if (normalizeOpName(raw.op) !== "update_props")
|
|
1472
|
+
return true;
|
|
1473
|
+
if (typeof raw.blockId === "string" && raw.blockId.length > 0)
|
|
1474
|
+
return true;
|
|
1475
|
+
const patch = patchObject(raw.patch);
|
|
1476
|
+
const hasPageLevelPatch = !!patch &&
|
|
1477
|
+
(typeof patch.slug === "string" || typeof patch.path === "string" || typeof patch.route === "string" || typeof patch.title === "string");
|
|
1478
|
+
const pathLooksLikeRoute = typeof raw.path === "string" && raw.path.startsWith("/");
|
|
1479
|
+
if (hasPageLevelPatch || pathLooksLikeRoute) {
|
|
1480
|
+
droppedPageLevelUpdate = true;
|
|
1481
|
+
return false;
|
|
1482
|
+
}
|
|
1483
|
+
return false;
|
|
1484
|
+
});
|
|
1485
|
+
if (droppedPageLevelUpdate && sanitizedOps.length === 0) {
|
|
1486
|
+
return {
|
|
1487
|
+
...root,
|
|
1488
|
+
intent: "needs_clarification",
|
|
1489
|
+
summary_for_user: "I could not infer a valid page operation. Specify the source and target routes explicitly.",
|
|
1490
|
+
change_log: [
|
|
1491
|
+
"Ignored an invalid page-level update_props operation that was missing blockId.",
|
|
1492
|
+
"Try: rename page from /old to /new, or delete page /path."
|
|
1493
|
+
],
|
|
1494
|
+
ops: []
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
// Reorder ops so create_page always precedes ops targeting the same slug.
|
|
1498
|
+
// The LLM may emit add_block ops before the corresponding create_page.
|
|
1499
|
+
const reorderedOps = reorderCreatePageFirst(sanitizedOps);
|
|
1500
|
+
// Ensure required EditPlan fields exist — smaller models sometimes omit them
|
|
1501
|
+
if (!root.intent) {
|
|
1502
|
+
root.intent = reorderedOps.length > 0 ? "edit_plan" : "needs_clarification";
|
|
1503
|
+
}
|
|
1504
|
+
if (!root.summary_for_user && typeof root.summary === "string") {
|
|
1505
|
+
root.summary_for_user = root.summary; // common model alias
|
|
1506
|
+
}
|
|
1507
|
+
if (typeof root.suggested_next_actions === "string") {
|
|
1508
|
+
root.suggested_next_actions = /^\s*\[\s*\]\s*$/.test(root.suggested_next_actions)
|
|
1509
|
+
? []
|
|
1510
|
+
: root.suggested_next_actions
|
|
1511
|
+
.split(/\n|[,;]/)
|
|
1512
|
+
.map((s) => s.replace(/^\s*[-•*\d.)\]]+\s*/, "").trim())
|
|
1513
|
+
.filter((s) => /\w/.test(s));
|
|
1514
|
+
}
|
|
1515
|
+
if (typeof root.change_log === "string") {
|
|
1516
|
+
// Models sometimes emit the literal string "[]" (or "[ ]") for an empty
|
|
1517
|
+
// change log; treat that as an empty array instead of a one-item "[]" list.
|
|
1518
|
+
root.change_log = /^\s*\[\s*\]\s*$/.test(root.change_log)
|
|
1519
|
+
? []
|
|
1520
|
+
: root.change_log
|
|
1521
|
+
.split(/\n/)
|
|
1522
|
+
.map((s) => s.replace(/^\s*[-•*\d.)\]]+\s*/, "").trim())
|
|
1523
|
+
.filter((s) => /\w/.test(s));
|
|
1524
|
+
}
|
|
1525
|
+
if (!Array.isArray(root.change_log)) {
|
|
1526
|
+
root.change_log = reorderedOps.map((op) => {
|
|
1527
|
+
const verb = op.op?.replace(/_/g, " ") ?? "modify";
|
|
1528
|
+
return `${verb} on ${op.pageSlug ?? "page"}`;
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
if (hasInlineOp) {
|
|
1532
|
+
if (reorderedOps.length === 1)
|
|
1533
|
+
return reorderedOps[0];
|
|
1534
|
+
return { ...root, ops: reorderedOps };
|
|
1535
|
+
}
|
|
1536
|
+
return { ...root, ops: reorderedOps };
|
|
1537
|
+
}
|