@avocadostudio-ai/orchestrator-core 0.11.4 → 0.11.6
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/dist/chat/chat-pipeline.js +2 -1
- package/dist/env-file-location.d.ts +22 -0
- package/dist/env-file-location.js +77 -0
- package/dist/handler/create-orchestrator.js +129 -5
- package/dist/nlp/deterministic-planner-suggestions.d.ts +25 -1
- package/dist/nlp/deterministic-planner-suggestions.js +30 -4
- package/dist/publish/diff-engine.js +48 -1
- package/package.json +3 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { keyFileHint } from "../env-file-location.js";
|
|
2
3
|
import { blockManifestSchema } from "@avocadostudio-ai/shared";
|
|
3
4
|
import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGeneratingPlaceholder, cleanupImagePlaceholders, buildPageDirectory, isVariationRequestMessage, variationVerbIntent, resolveEffectiveSlug, throwIfCanceled, raceCancel, sleepMs, suppressCancelOnly } from "./chat-pipeline-shared.js";
|
|
4
5
|
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
|
|
@@ -2997,7 +2998,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2997
2998
|
markPlanningStart();
|
|
2998
2999
|
try {
|
|
2999
3000
|
emitStatusTone("planning");
|
|
3000
|
-
const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType));
|
|
3001
|
+
const demoPlan = withKeylessNotice(demoPlanFromMessageImpl(plannerMessage, effectiveSlug, planningActiveBlockId, body.activeBlockType), keyFileHint());
|
|
3001
3002
|
markPlanningFinish();
|
|
3002
3003
|
const outcome = await respondFromPlan(demoPlan, "demo", applyMode, undefined, "demo");
|
|
3003
3004
|
if (outcome.done)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the absolute path of the file to add the key to.
|
|
3
|
+
*
|
|
4
|
+
* Walks up from `cwd` for a directory that already holds a `package.json`,
|
|
5
|
+
* taking an existing env file there if there is one and naming the file that
|
|
6
|
+
* *should* exist if there is not. Returns the caller's best guess rather than
|
|
7
|
+
* nothing: a path that does not exist yet is still the right answer to "where
|
|
8
|
+
* do I put this", and creating it is the instruction.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveEnvFilePath(cwd?: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* The path to show a user, or `undefined` when showing one would be wrong.
|
|
13
|
+
*
|
|
14
|
+
* Withheld in production for two reasons, and the second is the one that
|
|
15
|
+
* matters. It would put a server filesystem path into a browser — mildly
|
|
16
|
+
* careless on its own. But the advice attached to it ("add it to this file and
|
|
17
|
+
* restart the dev server") is also simply **false** in production: there is no
|
|
18
|
+
* dev server, the file is not deployed, and a key added to it changes nothing.
|
|
19
|
+
* A precise path attached to instructions that cannot work is worse than the
|
|
20
|
+
* vague version, because it reads as authoritative.
|
|
21
|
+
*/
|
|
22
|
+
export declare function keyFileHint(env?: NodeJS.ProcessEnv): string | undefined;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the user should actually put their API key.
|
|
3
|
+
*
|
|
4
|
+
* Every piece of copy in this product tells someone to "add a key to
|
|
5
|
+
* `.env.local` in your project root — the same folder as `package.json`", and
|
|
6
|
+
* none of it says *which* folder that is. The editor runs on its own origin,
|
|
7
|
+
* usually started by a script the user did not write, from a directory they may
|
|
8
|
+
* not have chosen; the terminal they are reading is running two processes; and
|
|
9
|
+
* the instruction arrives inside a browser tab that has no filesystem at all.
|
|
10
|
+
* "Your project root" is a description, not an address, and the person reading
|
|
11
|
+
* it is precisely the person who does not yet know the layout.
|
|
12
|
+
*
|
|
13
|
+
* The server does know. It is running in that directory. So it says so.
|
|
14
|
+
*
|
|
15
|
+
* Only in development, deliberately — see `keyFileHint`.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
/**
|
|
20
|
+
* The env files a key could live in, in the order the reader should prefer.
|
|
21
|
+
*
|
|
22
|
+
* `.env.local` first because that is what every doc and the scaffolder name,
|
|
23
|
+
* and because it is the one git ignores. `.env` second so an existing project
|
|
24
|
+
* that already keeps its secrets there is told to use the file it has rather
|
|
25
|
+
* than a second one that will silently take precedence.
|
|
26
|
+
*/
|
|
27
|
+
const CANDIDATES = [".env.local", ".env"];
|
|
28
|
+
/** How far up to look before giving up. A monorepo puts the app a few levels down. */
|
|
29
|
+
const MAX_DEPTH = 5;
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the absolute path of the file to add the key to.
|
|
32
|
+
*
|
|
33
|
+
* Walks up from `cwd` for a directory that already holds a `package.json`,
|
|
34
|
+
* taking an existing env file there if there is one and naming the file that
|
|
35
|
+
* *should* exist if there is not. Returns the caller's best guess rather than
|
|
36
|
+
* nothing: a path that does not exist yet is still the right answer to "where
|
|
37
|
+
* do I put this", and creating it is the instruction.
|
|
38
|
+
*/
|
|
39
|
+
export function resolveEnvFilePath(cwd = process.cwd()) {
|
|
40
|
+
let dir = resolve(cwd);
|
|
41
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
42
|
+
for (const candidate of CANDIDATES) {
|
|
43
|
+
if (existsSync(join(dir, candidate)))
|
|
44
|
+
return join(dir, candidate);
|
|
45
|
+
}
|
|
46
|
+
// No env file here, but if this is the project root then this is where one goes.
|
|
47
|
+
if (existsSync(join(dir, "package.json")))
|
|
48
|
+
return join(dir, CANDIDATES[0]);
|
|
49
|
+
const parent = dirname(dir);
|
|
50
|
+
if (parent === dir)
|
|
51
|
+
break;
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
return join(resolve(cwd), CANDIDATES[0]);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The path to show a user, or `undefined` when showing one would be wrong.
|
|
58
|
+
*
|
|
59
|
+
* Withheld in production for two reasons, and the second is the one that
|
|
60
|
+
* matters. It would put a server filesystem path into a browser — mildly
|
|
61
|
+
* careless on its own. But the advice attached to it ("add it to this file and
|
|
62
|
+
* restart the dev server") is also simply **false** in production: there is no
|
|
63
|
+
* dev server, the file is not deployed, and a key added to it changes nothing.
|
|
64
|
+
* A precise path attached to instructions that cannot work is worse than the
|
|
65
|
+
* vague version, because it reads as authoritative.
|
|
66
|
+
*/
|
|
67
|
+
export function keyFileHint(env = process.env) {
|
|
68
|
+
if ((env.NODE_ENV ?? "").trim() === "production")
|
|
69
|
+
return undefined;
|
|
70
|
+
try {
|
|
71
|
+
return resolveEnvFilePath();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// A sandboxed or read-only filesystem is not a reason to fail a chat turn.
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -21,7 +21,8 @@ import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
|
21
21
|
import { resolve, basename } from "node:path";
|
|
22
22
|
import { randomUUID } from "node:crypto";
|
|
23
23
|
import { z } from "zod";
|
|
24
|
-
import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes, EDITOR_PROTOCOL_VERSION, withLoopbackSpellings } from "@avocadostudio-ai/shared";
|
|
24
|
+
import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes, EDITOR_PROTOCOL_VERSION, withLoopbackSpellings, editorAllowHeaders } from "@avocadostudio-ai/shared";
|
|
25
|
+
import { keyFileHint } from "../env-file-location.js";
|
|
25
26
|
import { chatRequestBodySchema } from "../nlp/intent-detection.js";
|
|
26
27
|
import { applyOpsAtomically, pickFocusBlockId, pickUpdatedSlug, toErrorDetail, classifyGuardrailError } from "../ops/ops-engine.js";
|
|
27
28
|
import { runChatStream, formatSseFrame } from "../http/chat-stream.js";
|
|
@@ -316,6 +317,7 @@ const SUPPORTED_ROUTES = [
|
|
|
316
317
|
"POST /image/generate",
|
|
317
318
|
"POST /image/generate/chat",
|
|
318
319
|
"POST /image/interpret",
|
|
320
|
+
"POST /attachment/upload",
|
|
319
321
|
"POST /image/upload",
|
|
320
322
|
"GET /generated-images/:fileName"
|
|
321
323
|
];
|
|
@@ -347,7 +349,14 @@ const EXT_TO_MIME = {
|
|
|
347
349
|
webp: "image/webp",
|
|
348
350
|
gif: "image/gif",
|
|
349
351
|
avif: "image/avif",
|
|
350
|
-
svg: "image/svg+xml"
|
|
352
|
+
svg: "image/svg+xml",
|
|
353
|
+
/*
|
|
354
|
+
* Not an image, and it has to be here anyway: `/attachment/upload` accepts
|
|
355
|
+
* PDFs, and this map is what `/generated-images/` serves them back through.
|
|
356
|
+
* Without it a PDF uploads successfully and then 415s on the GET — stored,
|
|
357
|
+
* referenced in the chat, and unreadable.
|
|
358
|
+
*/
|
|
359
|
+
pdf: "application/pdf"
|
|
351
360
|
};
|
|
352
361
|
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "../cms/index.js";
|
|
353
362
|
function stripBasePath(pathname, basePath) {
|
|
@@ -527,7 +536,7 @@ export function createOrchestrator(config = {}) {
|
|
|
527
536
|
* would have been unreachable from the very client it is for.
|
|
528
537
|
*/
|
|
529
538
|
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
530
|
-
"access-control-allow-headers":
|
|
539
|
+
"access-control-allow-headers": editorAllowHeaders(),
|
|
531
540
|
"access-control-max-age": "600"
|
|
532
541
|
}
|
|
533
542
|
});
|
|
@@ -1129,6 +1138,17 @@ export function createOrchestrator(config = {}) {
|
|
|
1129
1138
|
...(config.siteName ? { name: config.siteName } : {}),
|
|
1130
1139
|
demoContent: config.demoContent === true
|
|
1131
1140
|
},
|
|
1141
|
+
/*
|
|
1142
|
+
* Where the user should put their key, as an absolute path.
|
|
1143
|
+
*
|
|
1144
|
+
* Every piece of copy naming `.env.local` describes a file without
|
|
1145
|
+
* addressing it, and the reader is in a browser tab with no
|
|
1146
|
+
* filesystem, on an origin that is not the site's. The server is
|
|
1147
|
+
* running in that directory, so it is the only party that can answer.
|
|
1148
|
+
* Absent in production, where the advice attached to it would be
|
|
1149
|
+
* false — see `env-file-location.ts`.
|
|
1150
|
+
*/
|
|
1151
|
+
...(keyFileHint() ? { keyFilePath: keyFileHint() } : {}),
|
|
1132
1152
|
features: {
|
|
1133
1153
|
googleDrive: false,
|
|
1134
1154
|
unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
|
|
@@ -1138,6 +1158,23 @@ export function createOrchestrator(config = {}) {
|
|
|
1138
1158
|
* editor offers the picker's CMS tab on it, so silence hides the
|
|
1139
1159
|
* tab rather than opening one onto a method nobody implemented. */
|
|
1140
1160
|
cmsMedia: runtime.capabilities.readsMedia,
|
|
1161
|
+
/*
|
|
1162
|
+
* Whether `/audio/transcribe` can actually answer.
|
|
1163
|
+
*
|
|
1164
|
+
* This key was simply absent from library mode's feature list while
|
|
1165
|
+
* the standalone server has always reported it, and the editor's
|
|
1166
|
+
* check is `audioTranscription !== false` — so `undefined` read as
|
|
1167
|
+
* *available* and the microphone shipped visible in every
|
|
1168
|
+
* scaffolded project, including with no key configured at all.
|
|
1169
|
+
* Pressing it records audio and posts it to a route that can only
|
|
1170
|
+
* answer 503.
|
|
1171
|
+
*
|
|
1172
|
+
* The same providers as the standalone server, for the same reason:
|
|
1173
|
+
* transcription tries OpenAI first and falls back to Gemini, so
|
|
1174
|
+
* either key enables it and neither means the control must not be
|
|
1175
|
+
* offered.
|
|
1176
|
+
*/
|
|
1177
|
+
audioTranscription: Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.GOOGLE_GENAI_API_KEY?.trim()),
|
|
1141
1178
|
agentMode: false
|
|
1142
1179
|
},
|
|
1143
1180
|
/*
|
|
@@ -1334,8 +1371,29 @@ export function createOrchestrator(config = {}) {
|
|
|
1334
1371
|
const scopedSession = scope(body.session, body.siteId);
|
|
1335
1372
|
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
1336
1373
|
const parsedOps = z.array(operationSchema).safeParse(body.ops);
|
|
1337
|
-
if (!parsedOps.success)
|
|
1338
|
-
|
|
1374
|
+
if (!parsedOps.success) {
|
|
1375
|
+
/*
|
|
1376
|
+
* Name the key, and say what an operation looks like.
|
|
1377
|
+
*
|
|
1378
|
+
* Zod reports the *failure*, not the *expectation*: a body with no
|
|
1379
|
+
* `ops` array produced `{"expected":"array","path":[],"message":
|
|
1380
|
+
* "expected array, received undefined"}` — a root-level path pointing
|
|
1381
|
+
* at nothing, never naming `ops`. Someone wiring this by hand from the
|
|
1382
|
+
* documented op vocabulary needed three consecutive 400s to discover
|
|
1383
|
+
* the envelope key, then that the discriminator is `op` and not `type`,
|
|
1384
|
+
* and only then saw the real shape. Each error revealed exactly one
|
|
1385
|
+
* layer, which is the most expensive way to publish a schema.
|
|
1386
|
+
*
|
|
1387
|
+
* `example` is a valid minimal operation on purpose: it answers all
|
|
1388
|
+
* three of those questions at once, in the first response.
|
|
1389
|
+
*/
|
|
1390
|
+
return jsonResponse({
|
|
1391
|
+
error: "invalid ops payload",
|
|
1392
|
+
expected: "a JSON body of { session, siteId, ops: Operation[] }",
|
|
1393
|
+
example: { session: "dev", siteId: "my-site", ops: [{ op: "update_props", pageSlug: "/", blockId: "hero-1", patch: { heading: "New heading" } }] },
|
|
1394
|
+
details: parsedOps.error.issues
|
|
1395
|
+
}, { status: 400, cors });
|
|
1396
|
+
}
|
|
1339
1397
|
if (parsedOps.data.length === 0)
|
|
1340
1398
|
return jsonResponse({ error: "ops must not be empty" }, { status: 400, cors });
|
|
1341
1399
|
let manifest;
|
|
@@ -2122,6 +2180,72 @@ export function createOrchestrator(config = {}) {
|
|
|
2122
2180
|
const bytes = Buffer.from(await file.arrayBuffer());
|
|
2123
2181
|
return actionResponse(await interpretImageAction({ bytes, byteLength: bytes.byteLength, mimeType: file.type }, { log: runtime.log }), cors);
|
|
2124
2182
|
}
|
|
2183
|
+
/*
|
|
2184
|
+
* The composer's paperclip, which library mode never answered.
|
|
2185
|
+
*
|
|
2186
|
+
* The editor's `useMediaInput` posts every chat attachment here,
|
|
2187
|
+
* unconditionally and with no capability probe, and this handler had no
|
|
2188
|
+
* such route — so the control shipped enabled on the first keyless screen
|
|
2189
|
+
* of every scaffolded project and answered `405` with
|
|
2190
|
+
* "not handled by createOrchestrator()" rendered in red under the composer.
|
|
2191
|
+
* It existed only in the standalone Fastify orchestrator
|
|
2192
|
+
* (`apps/orchestrator/src/routes/media.ts`), while library mode is what the
|
|
2193
|
+
* scaffold ships and what the docs call the shape most integrations use.
|
|
2194
|
+
* `features/chat-attachments.md` documents the flow with no caveat.
|
|
2195
|
+
*
|
|
2196
|
+
* Implemented rather than hidden, because nothing was actually missing:
|
|
2197
|
+
* `/image/upload` below already writes to `imageDir` and
|
|
2198
|
+
* `/generated-images/` already serves it back. The route was the only
|
|
2199
|
+
* absent part, and a documented feature is better answered than suppressed.
|
|
2200
|
+
*
|
|
2201
|
+
* Accepts either field name. Fastify's multipart reader takes the first
|
|
2202
|
+
* file part whatever it is called, so the standalone route never had to
|
|
2203
|
+
* care; a Web `FormData` does. The composer sends `file`, the image
|
|
2204
|
+
* endpoints send `image`, and making the two disagree across modes is how
|
|
2205
|
+
* this class of defect started.
|
|
2206
|
+
*/
|
|
2207
|
+
if (request.method === "POST" && path === "/attachment/upload") {
|
|
2208
|
+
let form;
|
|
2209
|
+
try {
|
|
2210
|
+
form = await request.formData();
|
|
2211
|
+
}
|
|
2212
|
+
catch {
|
|
2213
|
+
return jsonResponse({ error: "expected multipart/form-data body" }, { status: 400, cors });
|
|
2214
|
+
}
|
|
2215
|
+
const file = form.get("file") ?? form.get("image");
|
|
2216
|
+
if (!(file instanceof File)) {
|
|
2217
|
+
return jsonResponse({ error: "missing 'file' field" }, { status: 400, cors });
|
|
2218
|
+
}
|
|
2219
|
+
/*
|
|
2220
|
+
* PDFs are attachable and are not images, so the image MIME map is not
|
|
2221
|
+
* the right gate here — it is the gate for what a `<img>` can render.
|
|
2222
|
+
*/
|
|
2223
|
+
const ext = file.type === "application/pdf" ? "pdf" : MIME_TO_EXT[file.type];
|
|
2224
|
+
if (!ext) {
|
|
2225
|
+
return jsonResponse({ error: `unsupported attachment type: ${file.type || "unknown"}` }, { status: 415, cors });
|
|
2226
|
+
}
|
|
2227
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
2228
|
+
return jsonResponse({ error: `attachment exceeds ${MAX_UPLOAD_BYTES} byte limit` }, { status: 413, cors });
|
|
2229
|
+
}
|
|
2230
|
+
const fileName = `upload_${Date.now()}_${randomUUID().slice(0, 8)}.${ext}`;
|
|
2231
|
+
try {
|
|
2232
|
+
await mkdir(imageDir, { recursive: true });
|
|
2233
|
+
await writeFile(resolve(imageDir, fileName), Buffer.from(await file.arrayBuffer()));
|
|
2234
|
+
}
|
|
2235
|
+
catch (err) {
|
|
2236
|
+
return jsonResponse({ error: "attachment upload failed", detail: err instanceof Error ? err.message : String(err) }, { status: 500, cors });
|
|
2237
|
+
}
|
|
2238
|
+
return jsonResponse({
|
|
2239
|
+
url: `${basePath}/generated-images/${fileName}`,
|
|
2240
|
+
bytes: file.size,
|
|
2241
|
+
mimeType: file.type,
|
|
2242
|
+
// `name` and `kind` are what the composer renders in the attachment
|
|
2243
|
+
// chip. Without them it falls back to the local File, which is right
|
|
2244
|
+
// but makes the two modes' responses differ for no reason.
|
|
2245
|
+
name: file.name,
|
|
2246
|
+
kind: file.type === "application/pdf" ? "pdf" : "image"
|
|
2247
|
+
}, { status: 200, cors });
|
|
2248
|
+
}
|
|
2125
2249
|
if (request.method === "POST" && path === "/image/upload") {
|
|
2126
2250
|
let form;
|
|
2127
2251
|
try {
|
|
@@ -74,7 +74,31 @@ export declare function blockContractsSummary(manifest?: BlockManifest): Record<
|
|
|
74
74
|
* they are the phrasings this planner *can* execute without a key.
|
|
75
75
|
*/
|
|
76
76
|
export declare const KEYLESS_PLANNER_NOTICE: string;
|
|
77
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The same notice, without the *path*.
|
|
79
|
+
*
|
|
80
|
+
* An earlier version interpolated the absolute path of `.env.local` into the
|
|
81
|
+
* sentence. Correct, and it read appallingly: an 80-character path mid-
|
|
82
|
+
* paragraph, wrapping across four lines of a narrow pane, so a two-line
|
|
83
|
+
* explanation became a wall in which the actual advice was the least visible
|
|
84
|
+
* part — and it duplicated, word for word, the notice sitting directly below
|
|
85
|
+
* the conversation.
|
|
86
|
+
*
|
|
87
|
+
* The path belongs in exactly one place: the notice, where it is a selectable
|
|
88
|
+
* code block rather than prose. The variable names stay here, though, and a
|
|
89
|
+
* first attempt at removing those was wrong — `library-demo-identity.test.ts`
|
|
90
|
+
* pins them, and it is right to. **The notice is dismissible.** A message that
|
|
91
|
+
* says "see the notice below" is a dangling reference the moment someone closes
|
|
92
|
+
* it, and chat messages are also read in transcripts and telemetry where no
|
|
93
|
+
* notice exists. So this sentence has to stand alone; it just does not have to
|
|
94
|
+
* carry a filesystem path to do it.
|
|
95
|
+
*
|
|
96
|
+
* `envFilePath` is still accepted so callers need not know any of this. It
|
|
97
|
+
* changes nothing today and keeps the seam if a surface without the notice ever
|
|
98
|
+
* needs the long form.
|
|
99
|
+
*/
|
|
100
|
+
export declare function keylessPlannerNotice(_envFilePath?: string): string;
|
|
101
|
+
export declare function withKeylessNotice(plan: EditPlan, envFilePath?: string): EditPlan;
|
|
78
102
|
/**
|
|
79
103
|
* Drop the suggestions this planner could not act on.
|
|
80
104
|
*
|
|
@@ -625,12 +625,38 @@ export function blockContractsSummary(manifest) {
|
|
|
625
625
|
* So when the rules produce no operations, say why. The suggestions are kept:
|
|
626
626
|
* they are the phrasings this planner *can* execute without a key.
|
|
627
627
|
*/
|
|
628
|
-
export const KEYLESS_PLANNER_NOTICE = "
|
|
629
|
-
"
|
|
630
|
-
|
|
628
|
+
export const KEYLESS_PLANNER_NOTICE = "No API key is configured, so this is the built-in demo planner, which handles only simple, literal edits. " +
|
|
629
|
+
"Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your project's .env.local and restart the dev server for full chat.";
|
|
630
|
+
/**
|
|
631
|
+
* The same notice, without the *path*.
|
|
632
|
+
*
|
|
633
|
+
* An earlier version interpolated the absolute path of `.env.local` into the
|
|
634
|
+
* sentence. Correct, and it read appallingly: an 80-character path mid-
|
|
635
|
+
* paragraph, wrapping across four lines of a narrow pane, so a two-line
|
|
636
|
+
* explanation became a wall in which the actual advice was the least visible
|
|
637
|
+
* part — and it duplicated, word for word, the notice sitting directly below
|
|
638
|
+
* the conversation.
|
|
639
|
+
*
|
|
640
|
+
* The path belongs in exactly one place: the notice, where it is a selectable
|
|
641
|
+
* code block rather than prose. The variable names stay here, though, and a
|
|
642
|
+
* first attempt at removing those was wrong — `library-demo-identity.test.ts`
|
|
643
|
+
* pins them, and it is right to. **The notice is dismissible.** A message that
|
|
644
|
+
* says "see the notice below" is a dangling reference the moment someone closes
|
|
645
|
+
* it, and chat messages are also read in transcripts and telemetry where no
|
|
646
|
+
* notice exists. So this sentence has to stand alone; it just does not have to
|
|
647
|
+
* carry a filesystem path to do it.
|
|
648
|
+
*
|
|
649
|
+
* `envFilePath` is still accepted so callers need not know any of this. It
|
|
650
|
+
* changes nothing today and keeps the seam if a surface without the notice ever
|
|
651
|
+
* needs the long form.
|
|
652
|
+
*/
|
|
653
|
+
export function keylessPlannerNotice(_envFilePath) {
|
|
654
|
+
return KEYLESS_PLANNER_NOTICE;
|
|
655
|
+
}
|
|
656
|
+
export function withKeylessNotice(plan, envFilePath) {
|
|
631
657
|
if (plan.ops.length > 0)
|
|
632
658
|
return plan;
|
|
633
|
-
return { ...plan, summary_for_user:
|
|
659
|
+
return { ...plan, summary_for_user: keylessPlannerNotice(envFilePath) };
|
|
634
660
|
}
|
|
635
661
|
/**
|
|
636
662
|
* Drop the suggestions this planner could not act on.
|
|
@@ -159,15 +159,53 @@ function diffPage(before, after) {
|
|
|
159
159
|
const a = after;
|
|
160
160
|
const blockDiffs = diffBlocks(b.blocks, a.blocks);
|
|
161
161
|
const titleChanged = b.title !== a.title;
|
|
162
|
+
const metaDiffs = diffPageMeta(b, a);
|
|
162
163
|
const hasStructuralChange = blockDiffs.some((bd) => bd.status !== "unchanged");
|
|
163
164
|
return {
|
|
164
165
|
slug: a.slug,
|
|
165
|
-
status: titleChanged || hasStructuralChange ? "modified" : "unchanged",
|
|
166
|
+
status: titleChanged || metaDiffs.length > 0 || hasStructuralChange ? "modified" : "unchanged",
|
|
166
167
|
titleBefore: titleChanged ? b.title : undefined,
|
|
167
168
|
titleAfter: titleChanged ? a.title : undefined,
|
|
169
|
+
...(metaDiffs.length > 0 ? { metaDiffs } : {}),
|
|
168
170
|
blockDiffs,
|
|
169
171
|
};
|
|
170
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Page-level SEO fields, which the diff did not look at.
|
|
175
|
+
*
|
|
176
|
+
* A page's `meta` carries the title, description, Open Graph image and public
|
|
177
|
+
* path — everything a crawler and a social card read, and the whole content of
|
|
178
|
+
* the property panel's **SEO** section. None of it reached the publish review,
|
|
179
|
+
* so a user who edited their page title saw "1 page modified · 1 field changed"
|
|
180
|
+
* counting only the block they had also touched, and no mention of the title at
|
|
181
|
+
* all. The draft held the change; the screen that exists to show what will
|
|
182
|
+
* change did not show it.
|
|
183
|
+
*
|
|
184
|
+
* `ogImage` is typed as an image so the UI renders a thumbnail rather than a
|
|
185
|
+
* URL, matching how block image fields already behave.
|
|
186
|
+
*/
|
|
187
|
+
const PAGE_META_FIELDS = [
|
|
188
|
+
["title", "text"],
|
|
189
|
+
["description", "text"],
|
|
190
|
+
["ogImage", "image"],
|
|
191
|
+
["path", "other"]
|
|
192
|
+
];
|
|
193
|
+
function diffPageMeta(before, after) {
|
|
194
|
+
const b = (before.meta ?? {});
|
|
195
|
+
const a = (after.meta ?? {});
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const [key, kind] of PAGE_META_FIELDS) {
|
|
198
|
+
const bv = b[key];
|
|
199
|
+
const av = a[key];
|
|
200
|
+
// Absent and empty are the same thing to a reader, and treating them as
|
|
201
|
+
// different produced phantom diffs on pages that had never had a value.
|
|
202
|
+
const normalise = (v) => (v === undefined || v === null || v === "" ? undefined : v);
|
|
203
|
+
if (normalise(bv) === normalise(av))
|
|
204
|
+
continue;
|
|
205
|
+
out.push({ path: `meta.${key}`, before: bv, after: av, kind });
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
171
209
|
// Keys of SiteConfig that drive the rendered SiteHeader chrome. We diff
|
|
172
210
|
// only these to keep the publish UI focused on user-visible header changes;
|
|
173
211
|
// `purpose`, `tone`, `constraints`, `themeOverrides` are AI/style context
|
|
@@ -276,6 +314,15 @@ export function computePublishDiff(draft, published, options) {
|
|
|
276
314
|
if (bd.fieldDiffs)
|
|
277
315
|
totalChangedFields += bd.fieldDiffs.length;
|
|
278
316
|
}
|
|
317
|
+
/*
|
|
318
|
+
* Page metadata counts as changed fields too.
|
|
319
|
+
*
|
|
320
|
+
* Three counters used to disagree on one screen — "1 field changed" here
|
|
321
|
+
* (blocks only), "2 changes" on the page row (which counted an added
|
|
322
|
+
* block), and "Publish 1 change" on the button — and a metadata edit moved
|
|
323
|
+
* none of them.
|
|
324
|
+
*/
|
|
325
|
+
totalChangedFields += diff.metaDiffs?.length ?? 0;
|
|
279
326
|
}
|
|
280
327
|
const statusOrder = {
|
|
281
328
|
added: 0,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/orchestrator-core",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"openai": "^4.87.1",
|
|
23
23
|
"sharp": "^0.35.4",
|
|
24
24
|
"zod": "^4.3.6",
|
|
25
|
-
"@avocadostudio-ai/
|
|
26
|
-
"@avocadostudio-ai/
|
|
25
|
+
"@avocadostudio-ai/migration-sdk": "^0.11.6",
|
|
26
|
+
"@avocadostudio-ai/shared": "^0.11.6"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|