@avocadostudio-ai/orchestrator-core 0.11.4 → 0.11.5
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 +110 -3
- package/dist/nlp/deterministic-planner-suggestions.d.ts +16 -1
- package/dist/nlp/deterministic-planner-suggestions.js +22 -2
- 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
|
+
}
|
|
@@ -22,6 +22,7 @@ import { resolve, basename } from "node:path";
|
|
|
22
22
|
import { randomUUID } from "node:crypto";
|
|
23
23
|
import { z } from "zod";
|
|
24
24
|
import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes, EDITOR_PROTOCOL_VERSION, withLoopbackSpellings } 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) {
|
|
@@ -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),
|
|
@@ -1334,8 +1354,29 @@ export function createOrchestrator(config = {}) {
|
|
|
1334
1354
|
const scopedSession = scope(body.session, body.siteId);
|
|
1335
1355
|
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
1336
1356
|
const parsedOps = z.array(operationSchema).safeParse(body.ops);
|
|
1337
|
-
if (!parsedOps.success)
|
|
1338
|
-
|
|
1357
|
+
if (!parsedOps.success) {
|
|
1358
|
+
/*
|
|
1359
|
+
* Name the key, and say what an operation looks like.
|
|
1360
|
+
*
|
|
1361
|
+
* Zod reports the *failure*, not the *expectation*: a body with no
|
|
1362
|
+
* `ops` array produced `{"expected":"array","path":[],"message":
|
|
1363
|
+
* "expected array, received undefined"}` — a root-level path pointing
|
|
1364
|
+
* at nothing, never naming `ops`. Someone wiring this by hand from the
|
|
1365
|
+
* documented op vocabulary needed three consecutive 400s to discover
|
|
1366
|
+
* the envelope key, then that the discriminator is `op` and not `type`,
|
|
1367
|
+
* and only then saw the real shape. Each error revealed exactly one
|
|
1368
|
+
* layer, which is the most expensive way to publish a schema.
|
|
1369
|
+
*
|
|
1370
|
+
* `example` is a valid minimal operation on purpose: it answers all
|
|
1371
|
+
* three of those questions at once, in the first response.
|
|
1372
|
+
*/
|
|
1373
|
+
return jsonResponse({
|
|
1374
|
+
error: "invalid ops payload",
|
|
1375
|
+
expected: "a JSON body of { session, siteId, ops: Operation[] }",
|
|
1376
|
+
example: { session: "dev", siteId: "my-site", ops: [{ op: "update_props", pageSlug: "/", blockId: "hero-1", patch: { heading: "New heading" } }] },
|
|
1377
|
+
details: parsedOps.error.issues
|
|
1378
|
+
}, { status: 400, cors });
|
|
1379
|
+
}
|
|
1339
1380
|
if (parsedOps.data.length === 0)
|
|
1340
1381
|
return jsonResponse({ error: "ops must not be empty" }, { status: 400, cors });
|
|
1341
1382
|
let manifest;
|
|
@@ -2122,6 +2163,72 @@ export function createOrchestrator(config = {}) {
|
|
|
2122
2163
|
const bytes = Buffer.from(await file.arrayBuffer());
|
|
2123
2164
|
return actionResponse(await interpretImageAction({ bytes, byteLength: bytes.byteLength, mimeType: file.type }, { log: runtime.log }), cors);
|
|
2124
2165
|
}
|
|
2166
|
+
/*
|
|
2167
|
+
* The composer's paperclip, which library mode never answered.
|
|
2168
|
+
*
|
|
2169
|
+
* The editor's `useMediaInput` posts every chat attachment here,
|
|
2170
|
+
* unconditionally and with no capability probe, and this handler had no
|
|
2171
|
+
* such route — so the control shipped enabled on the first keyless screen
|
|
2172
|
+
* of every scaffolded project and answered `405` with
|
|
2173
|
+
* "not handled by createOrchestrator()" rendered in red under the composer.
|
|
2174
|
+
* It existed only in the standalone Fastify orchestrator
|
|
2175
|
+
* (`apps/orchestrator/src/routes/media.ts`), while library mode is what the
|
|
2176
|
+
* scaffold ships and what the docs call the shape most integrations use.
|
|
2177
|
+
* `features/chat-attachments.md` documents the flow with no caveat.
|
|
2178
|
+
*
|
|
2179
|
+
* Implemented rather than hidden, because nothing was actually missing:
|
|
2180
|
+
* `/image/upload` below already writes to `imageDir` and
|
|
2181
|
+
* `/generated-images/` already serves it back. The route was the only
|
|
2182
|
+
* absent part, and a documented feature is better answered than suppressed.
|
|
2183
|
+
*
|
|
2184
|
+
* Accepts either field name. Fastify's multipart reader takes the first
|
|
2185
|
+
* file part whatever it is called, so the standalone route never had to
|
|
2186
|
+
* care; a Web `FormData` does. The composer sends `file`, the image
|
|
2187
|
+
* endpoints send `image`, and making the two disagree across modes is how
|
|
2188
|
+
* this class of defect started.
|
|
2189
|
+
*/
|
|
2190
|
+
if (request.method === "POST" && path === "/attachment/upload") {
|
|
2191
|
+
let form;
|
|
2192
|
+
try {
|
|
2193
|
+
form = await request.formData();
|
|
2194
|
+
}
|
|
2195
|
+
catch {
|
|
2196
|
+
return jsonResponse({ error: "expected multipart/form-data body" }, { status: 400, cors });
|
|
2197
|
+
}
|
|
2198
|
+
const file = form.get("file") ?? form.get("image");
|
|
2199
|
+
if (!(file instanceof File)) {
|
|
2200
|
+
return jsonResponse({ error: "missing 'file' field" }, { status: 400, cors });
|
|
2201
|
+
}
|
|
2202
|
+
/*
|
|
2203
|
+
* PDFs are attachable and are not images, so the image MIME map is not
|
|
2204
|
+
* the right gate here — it is the gate for what a `<img>` can render.
|
|
2205
|
+
*/
|
|
2206
|
+
const ext = file.type === "application/pdf" ? "pdf" : MIME_TO_EXT[file.type];
|
|
2207
|
+
if (!ext) {
|
|
2208
|
+
return jsonResponse({ error: `unsupported attachment type: ${file.type || "unknown"}` }, { status: 415, cors });
|
|
2209
|
+
}
|
|
2210
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
2211
|
+
return jsonResponse({ error: `attachment exceeds ${MAX_UPLOAD_BYTES} byte limit` }, { status: 413, cors });
|
|
2212
|
+
}
|
|
2213
|
+
const fileName = `upload_${Date.now()}_${randomUUID().slice(0, 8)}.${ext}`;
|
|
2214
|
+
try {
|
|
2215
|
+
await mkdir(imageDir, { recursive: true });
|
|
2216
|
+
await writeFile(resolve(imageDir, fileName), Buffer.from(await file.arrayBuffer()));
|
|
2217
|
+
}
|
|
2218
|
+
catch (err) {
|
|
2219
|
+
return jsonResponse({ error: "attachment upload failed", detail: err instanceof Error ? err.message : String(err) }, { status: 500, cors });
|
|
2220
|
+
}
|
|
2221
|
+
return jsonResponse({
|
|
2222
|
+
url: `${basePath}/generated-images/${fileName}`,
|
|
2223
|
+
bytes: file.size,
|
|
2224
|
+
mimeType: file.type,
|
|
2225
|
+
// `name` and `kind` are what the composer renders in the attachment
|
|
2226
|
+
// chip. Without them it falls back to the local File, which is right
|
|
2227
|
+
// but makes the two modes' responses differ for no reason.
|
|
2228
|
+
name: file.name,
|
|
2229
|
+
kind: file.type === "application/pdf" ? "pdf" : "image"
|
|
2230
|
+
}, { status: 200, cors });
|
|
2231
|
+
}
|
|
2125
2232
|
if (request.method === "POST" && path === "/image/upload") {
|
|
2126
2233
|
let form;
|
|
2127
2234
|
try {
|
|
@@ -74,7 +74,22 @@ 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, naming the file by its full path when we can.
|
|
79
|
+
*
|
|
80
|
+
* "Add it to `.env.local`" is a description of a file, not an address, and the
|
|
81
|
+
* person reading it is by definition the one who does not yet know the layout:
|
|
82
|
+
* the message arrives in a browser tab, on an origin that is not the site's,
|
|
83
|
+
* from a server started by a script they did not write. Saying *which*
|
|
84
|
+
* `.env.local` removes the only step in this instruction that requires a guess.
|
|
85
|
+
*
|
|
86
|
+
* Falls back to the constant above whenever a path would be wrong or
|
|
87
|
+
* unavailable — in production there is no dev server to restart and the file is
|
|
88
|
+
* not deployed, so a precise path there would be authoritative and false. See
|
|
89
|
+
* `keyFileHint`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function keylessPlannerNotice(envFilePath?: string): string;
|
|
92
|
+
export declare function withKeylessNotice(plan: EditPlan, envFilePath?: string): EditPlan;
|
|
78
93
|
/**
|
|
79
94
|
* Drop the suggestions this planner could not act on.
|
|
80
95
|
*
|
|
@@ -627,10 +627,30 @@ export function blockContractsSummary(manifest) {
|
|
|
627
627
|
*/
|
|
628
628
|
export const KEYLESS_PLANNER_NOTICE = "There's no AI key configured, so I'm running on the built-in demo planner — it only handles simple, literal edits. " +
|
|
629
629
|
"Add ANTHROPIC_API_KEY (or OPENAI_API_KEY) to .env.local and restart the dev server to chat for real.";
|
|
630
|
-
|
|
630
|
+
/**
|
|
631
|
+
* The same notice, naming the file by its full path when we can.
|
|
632
|
+
*
|
|
633
|
+
* "Add it to `.env.local`" is a description of a file, not an address, and the
|
|
634
|
+
* person reading it is by definition the one who does not yet know the layout:
|
|
635
|
+
* the message arrives in a browser tab, on an origin that is not the site's,
|
|
636
|
+
* from a server started by a script they did not write. Saying *which*
|
|
637
|
+
* `.env.local` removes the only step in this instruction that requires a guess.
|
|
638
|
+
*
|
|
639
|
+
* Falls back to the constant above whenever a path would be wrong or
|
|
640
|
+
* unavailable — in production there is no dev server to restart and the file is
|
|
641
|
+
* not deployed, so a precise path there would be authoritative and false. See
|
|
642
|
+
* `keyFileHint`.
|
|
643
|
+
*/
|
|
644
|
+
export function keylessPlannerNotice(envFilePath) {
|
|
645
|
+
if (!envFilePath)
|
|
646
|
+
return KEYLESS_PLANNER_NOTICE;
|
|
647
|
+
return ("There's no AI key configured, so I'm running on the built-in demo planner — it only handles simple, literal edits. " +
|
|
648
|
+
`Add ANTHROPIC_API_KEY (or OPENAI_API_KEY) to ${envFilePath} and restart the dev server to chat for real.`);
|
|
649
|
+
}
|
|
650
|
+
export function withKeylessNotice(plan, envFilePath) {
|
|
631
651
|
if (plan.ops.length > 0)
|
|
632
652
|
return plan;
|
|
633
|
-
return { ...plan, summary_for_user:
|
|
653
|
+
return { ...plan, summary_for_user: keylessPlannerNotice(envFilePath) };
|
|
634
654
|
}
|
|
635
655
|
/**
|
|
636
656
|
* Drop the suggestions this planner could not act on.
|
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.5",
|
|
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.5",
|
|
26
|
+
"@avocadostudio-ai/shared": "^0.11.5"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|