@avocadostudio-ai/orchestrator-core 0.2.1 → 0.3.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/README.md +102 -0
- package/dist/chat/changelog-coverage-validator.d.ts +12 -0
- package/dist/chat/changelog-coverage-validator.js +25 -2
- package/dist/chat/chat-pipeline.js +49 -4
- package/dist/chat/hallucination-validator.d.ts +5 -0
- package/dist/chat/hallucination-validator.js +13 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +2 -1
- package/dist/cms/index.js +1 -0
- package/dist/cms/media-sources.d.ts +49 -0
- package/dist/cms/media-sources.js +182 -0
- package/dist/handler/auth.js +7 -1
- package/dist/handler/create-orchestrator.js +85 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/ops/ops-engine.d.ts +17 -0
- package/dist/ops/ops-engine.js +19 -0
- package/package.json +14 -3
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @avocadostudio-ai/orchestrator-core
|
|
2
|
+
|
|
3
|
+
The Avocado Studio orchestrator as a library: session state, AI planning, the
|
|
4
|
+
operations engine, and publishing, behind one Web-standard
|
|
5
|
+
`(Request) => Promise<Response>`.
|
|
6
|
+
|
|
7
|
+
This is the package **library mode is built on**. If you are integrating a
|
|
8
|
+
Next.js site you almost certainly want
|
|
9
|
+
[`@avocadostudio-ai/site-sdk`](https://www.npmjs.com/package/@avocadostudio-ai/site-sdk)
|
|
10
|
+
instead — it re-exports everything below from
|
|
11
|
+
`@avocadostudio-ai/site-sdk/server` and adds the Next-specific pieces (draft
|
|
12
|
+
mode, the page factory, the editor API route). Reach for this package directly
|
|
13
|
+
only when your host is not Next.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @avocadostudio-ai/orchestrator-core
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`better-sqlite3` is a real dependency, not optional: session state — draft
|
|
22
|
+
pages, undo/redo, the version log, chat history — lives in SQLite. Prebuilt
|
|
23
|
+
binaries ship for linux-x64 (glibc 2.28+), darwin-arm64 and darwin-x64 on
|
|
24
|
+
Node 22.
|
|
25
|
+
|
|
26
|
+
Two peers are **optional**: `googleapis` and `@google/genai`. They are reached
|
|
27
|
+
through `await import(...)` so a deployment that uses neither need not install
|
|
28
|
+
them — but a bundler resolves dynamic imports statically and will fail the build
|
|
29
|
+
over a package that is deliberately absent. Mark them external. On Next,
|
|
30
|
+
`withAvocado` from `@avocadostudio-ai/site-sdk/next-config` does it for you.
|
|
31
|
+
|
|
32
|
+
## Mount it
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createOrchestrator } from "@avocadostudio-ai/orchestrator-core"
|
|
36
|
+
|
|
37
|
+
const handler = createOrchestrator({
|
|
38
|
+
basePath: "/api/avocado",
|
|
39
|
+
adapter: myAdapter,
|
|
40
|
+
auth: async (request) => Boolean(await getSession(request)),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Any host that speaks Request/Response
|
|
44
|
+
export { handler as GET, handler as POST, handler as OPTIONS }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`createOrchestrator` gates **every** route. With neither an `auth` hook nor a
|
|
48
|
+
credential (`ACCESS_PASSWORD_HASH` or `ORCHESTRATOR_ACCESS_TOKEN`) it refuses
|
|
49
|
+
all requests under `NODE_ENV=production` rather than serving your content
|
|
50
|
+
openly. Only `/auth/status` and `/auth/verify` are public.
|
|
51
|
+
|
|
52
|
+
## The adapter
|
|
53
|
+
|
|
54
|
+
`adapter` is your content store. SQLite is the working copy; the adapter is the
|
|
55
|
+
source of truth, read on a cold session and written on publish.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import type { CmsAdapter } from "@avocadostudio-ai/orchestrator-core/cms"
|
|
59
|
+
|
|
60
|
+
const myAdapter: CmsAdapter = {
|
|
61
|
+
id: "my-cms",
|
|
62
|
+
perspectives: true, // does getPages honour options.perspective?
|
|
63
|
+
getPages: (options) => fetchPages(options),
|
|
64
|
+
onPublish: (pages, config, context) => writeBack(pages, context?.published),
|
|
65
|
+
capabilities: { createPage: false }, // static — /whoami answers it offline
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`jsonFileAdapter` and `editorApiAdapter` are bundled from the same subpath. The
|
|
70
|
+
full contract — including why `perspectives` defaults to *no* while capabilities
|
|
71
|
+
default to *yes*, and why `onPublish` has to diff rather than overwrite — is
|
|
72
|
+
documented in
|
|
73
|
+
[the site-sdk README](https://www.npmjs.com/package/@avocadostudio-ai/site-sdk#the-adapter-contract).
|
|
74
|
+
|
|
75
|
+
## What it does not do
|
|
76
|
+
|
|
77
|
+
It serves the API. It does not serve the editor UI — that is
|
|
78
|
+
`@avocadostudio-ai/cli` or your own deployment of the Studio — and it does not
|
|
79
|
+
render your pages.
|
|
80
|
+
|
|
81
|
+
`/health` is **not** a route, so a client that probes it to check compatibility
|
|
82
|
+
gets a 405. The body lists every route that does exist.
|
|
83
|
+
|
|
84
|
+
## Persistence
|
|
85
|
+
|
|
86
|
+
| variable | meaning |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `ORCHESTRATOR_DB_FILE` | Path to the SQLite file. Empty for the default; `:memory:` to force ephemeral. Auto-`:memory:` under `NODE_ENV=test` |
|
|
89
|
+
| `ORCHESTRATOR_DB_BACKUP_INTERVAL_HOURS` | Periodic `VACUUM INTO` snapshot interval (default 24) |
|
|
90
|
+
| `ORCHESTRATOR_DB_BACKUP_LIMIT` | Rolling snapshots to keep (default 14) |
|
|
91
|
+
|
|
92
|
+
Undo/redo is capped at 50 entries per slug per direction, the version log at
|
|
93
|
+
100, recent edits at 10, chat history at 6 messages.
|
|
94
|
+
|
|
95
|
+
## Providers
|
|
96
|
+
|
|
97
|
+
At least one of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` or `GOOGLE_GENAI_API_KEY`
|
|
98
|
+
is required for planning. Keys never leave the process that holds them.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
Apache-2.0
|
|
@@ -34,4 +34,16 @@ export type ChangelogCoverageResult = {
|
|
|
34
34
|
export declare function validateChangelogCoverage(args: {
|
|
35
35
|
plan: EditPlan;
|
|
36
36
|
draft: Map<string, PageDoc>;
|
|
37
|
+
/**
|
|
38
|
+
* Trailing `change_log` entries that describe the turn rather than an op —
|
|
39
|
+
* currently just the note the hallucination validator appends when it strips
|
|
40
|
+
* a prop the block does not have.
|
|
41
|
+
*
|
|
42
|
+
* They are neither counted against `ops.length` nor trimmed. Without this the
|
|
43
|
+
* arithmetic below sees N ops and N+1 entries, calls the note the planner
|
|
44
|
+
* over-describing itself, and deletes it — and that note is the only place
|
|
45
|
+
* the user is told a field they asked for does not exist. Two validators
|
|
46
|
+
* twenty lines apart, one erasing the other's output.
|
|
47
|
+
*/
|
|
48
|
+
notes?: string[];
|
|
37
49
|
}): ChangelogCoverageResult;
|
|
@@ -156,6 +156,22 @@ function describeOp(op, draft) {
|
|
|
156
156
|
*/
|
|
157
157
|
export function validateChangelogCoverage(args) {
|
|
158
158
|
const { plan, draft } = args;
|
|
159
|
+
/*
|
|
160
|
+
* Split the notes off the tail, run the op-vs-narration arithmetic on what is
|
|
161
|
+
* left, and put them back. `notes` are appended last by construction, so this
|
|
162
|
+
* is a suffix check rather than a search.
|
|
163
|
+
*/
|
|
164
|
+
const notes = [];
|
|
165
|
+
for (const note of [...(args.notes ?? [])].reverse()) {
|
|
166
|
+
if (plan.change_log[plan.change_log.length - 1] !== note)
|
|
167
|
+
continue;
|
|
168
|
+
plan.change_log = plan.change_log.slice(0, -1);
|
|
169
|
+
notes.unshift(note);
|
|
170
|
+
}
|
|
171
|
+
const restoreNotes = () => {
|
|
172
|
+
if (notes.length > 0)
|
|
173
|
+
plan.change_log = [...plan.change_log, ...notes];
|
|
174
|
+
};
|
|
159
175
|
const empty = {
|
|
160
176
|
plan,
|
|
161
177
|
missingCount: 0,
|
|
@@ -165,8 +181,10 @@ export function validateChangelogCoverage(args) {
|
|
|
165
181
|
fieldMislabelCount: 0,
|
|
166
182
|
relabeledEntries: []
|
|
167
183
|
};
|
|
168
|
-
if (plan.intent !== "edit_plan")
|
|
184
|
+
if (plan.intent !== "edit_plan") {
|
|
185
|
+
restoreNotes();
|
|
169
186
|
return empty;
|
|
187
|
+
}
|
|
170
188
|
const opCount = plan.ops.length;
|
|
171
189
|
// Field-level pass (#13): an update_props entry can be the right COUNT yet name
|
|
172
190
|
// the wrong field. Relabel each mislabeled update_props entry from the op's
|
|
@@ -190,13 +208,16 @@ export function validateChangelogCoverage(args) {
|
|
|
190
208
|
}
|
|
191
209
|
const changeCount = plan.change_log.length;
|
|
192
210
|
if (changeCount === opCount) {
|
|
211
|
+
restoreNotes();
|
|
193
212
|
return { ...empty, fieldMislabelCount: relabeledEntries.length, relabeledEntries };
|
|
194
213
|
}
|
|
195
214
|
// Empty ops list with non-empty change_log is almost always a planner
|
|
196
215
|
// confusion (intent should have been content_answer). Leave it for
|
|
197
216
|
// higher-level handling rather than wiping the user-facing copy here.
|
|
198
|
-
if (opCount === 0)
|
|
217
|
+
if (opCount === 0) {
|
|
218
|
+
restoreNotes();
|
|
199
219
|
return empty;
|
|
220
|
+
}
|
|
200
221
|
if (changeCount < opCount) {
|
|
201
222
|
const missingCount = opCount - changeCount;
|
|
202
223
|
const synthesizedEntries = [];
|
|
@@ -204,6 +225,7 @@ export function validateChangelogCoverage(args) {
|
|
|
204
225
|
synthesizedEntries.push(describeOp(plan.ops[i], draft));
|
|
205
226
|
}
|
|
206
227
|
plan.change_log = [...plan.change_log, ...synthesizedEntries];
|
|
228
|
+
restoreNotes();
|
|
207
229
|
return { plan, missingCount, synthesizedEntries, extraCount: 0, droppedEntries: [], fieldMislabelCount: relabeledEntries.length, relabeledEntries };
|
|
208
230
|
}
|
|
209
231
|
// changeCount > opCount: planner described more changes than it emitted ops
|
|
@@ -211,5 +233,6 @@ export function validateChangelogCoverage(args) {
|
|
|
211
233
|
const extraCount = changeCount - opCount;
|
|
212
234
|
const droppedEntries = plan.change_log.slice(opCount);
|
|
213
235
|
plan.change_log = plan.change_log.slice(0, opCount);
|
|
236
|
+
restoreNotes();
|
|
214
237
|
return { plan, missingCount: 0, synthesizedEntries: [], extraCount, droppedEntries, fieldMislabelCount: relabeledEntries.length, relabeledEntries };
|
|
215
238
|
}
|
|
@@ -4,7 +4,7 @@ import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGenerating
|
|
|
4
4
|
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
|
|
5
5
|
import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
|
|
6
6
|
import { versions, pendingClarificationBySession, chatHistoryBySession, pendingApprovalPlanBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
|
|
7
|
-
import { toErrorDetail, isNoEffectiveChangeError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
7
|
+
import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
8
8
|
import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
|
|
9
9
|
import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
|
|
10
10
|
import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
|
|
@@ -1101,6 +1101,14 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1101
1101
|
const respondFromPlan = async (plan, source, applyMode = "apply_now", optionsOverride, plannerTier) => {
|
|
1102
1102
|
if (plannerTier)
|
|
1103
1103
|
activePlannerTier = plannerTier;
|
|
1104
|
+
/*
|
|
1105
|
+
* The note the hallucination validator writes when it strips a prop the
|
|
1106
|
+
* block does not have, hoisted so the no-effective-change handler far below
|
|
1107
|
+
* can reach it. That handler replaces `summary_for_user` wholesale, and a
|
|
1108
|
+
* plan whose every prop was stripped lands there — so without this the one
|
|
1109
|
+
* true sentence written about the turn is the one the user never sees.
|
|
1110
|
+
*/
|
|
1111
|
+
let strippedPropsNote;
|
|
1104
1112
|
const usageFields = planUsage ? {
|
|
1105
1113
|
inputTokens: planUsage.inputTokens,
|
|
1106
1114
|
outputTokens: planUsage.outputTokens,
|
|
@@ -1152,6 +1160,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1152
1160
|
plan: resolvedPlan,
|
|
1153
1161
|
draft: getSessionDraft(body.session)
|
|
1154
1162
|
});
|
|
1163
|
+
strippedPropsNote = hallucinationResult.note;
|
|
1155
1164
|
if (hallucinationResult.hallucinatedProps.length > 0) {
|
|
1156
1165
|
for (const entry of hallucinationResult.hallucinatedProps) {
|
|
1157
1166
|
ctx.log.warn({
|
|
@@ -1173,7 +1182,9 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1173
1182
|
// sees the correct count instead of silently approving uncovered ops.
|
|
1174
1183
|
const changelogResult = validateChangelogCoverage({
|
|
1175
1184
|
plan: resolvedPlan,
|
|
1176
|
-
draft: getSessionDraft(body.session)
|
|
1185
|
+
draft: getSessionDraft(body.session),
|
|
1186
|
+
// Not an op narration, so not something to trim for over-describing.
|
|
1187
|
+
notes: hallucinationResult.note ? [hallucinationResult.note] : undefined
|
|
1177
1188
|
});
|
|
1178
1189
|
if (changelogResult.missingCount > 0) {
|
|
1179
1190
|
ctx.log.warn({
|
|
@@ -2540,13 +2551,47 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2540
2551
|
...plannerContextTelemetryFields,
|
|
2541
2552
|
...timingFields()
|
|
2542
2553
|
});
|
|
2554
|
+
/*
|
|
2555
|
+
* Two facts arrive here and only one of them is "your content is
|
|
2556
|
+
* already correct".
|
|
2557
|
+
*
|
|
2558
|
+
* The engine throws "All update patches matched existing values" when
|
|
2559
|
+
* the values genuinely matched, and "Edit plan produced no changes"
|
|
2560
|
+
* when the plan had nothing left to apply — which is what a plan looks
|
|
2561
|
+
* like after every prop in it was stripped as unsupported by the block.
|
|
2562
|
+
* Answering both with `status: "applied"` and "already up to date"
|
|
2563
|
+
* tells an author their content is fine when their request was in fact
|
|
2564
|
+
* dropped, and it is the shape of failure nobody reports, because the
|
|
2565
|
+
* product said it worked.
|
|
2566
|
+
*
|
|
2567
|
+
* The two sibling branches in this file already draw this line — the
|
|
2568
|
+
* approval-gate no-op at `planPreview.appliedCount === 0` and the
|
|
2569
|
+
* empty-plan branch that refuses to claim up-to-date-ness for an
|
|
2570
|
+
* `edit_plan` intent. This was the third one, and it was the one an
|
|
2571
|
+
* adopter hit.
|
|
2572
|
+
*/
|
|
2573
|
+
/*
|
|
2574
|
+
* A note from the hallucination validator outranks both engine
|
|
2575
|
+
* messages. It means this turn asked for a field the block does not
|
|
2576
|
+
* have, which is a better answer than either of them and the only one
|
|
2577
|
+
* that names the block — and it is true whichever message the engine
|
|
2578
|
+
* happened to throw, since an op stripped to an empty patch can land in
|
|
2579
|
+
* `skippedOps` and read as "matched existing values".
|
|
2580
|
+
*/
|
|
2581
|
+
const noopSummary = strippedPropsNote ??
|
|
2582
|
+
(isAlreadyCurrentError(reason)
|
|
2583
|
+
? "No changes needed — that content is already set."
|
|
2584
|
+
: "I couldn't make that change — it isn't supported on this block. Try a different field or wording.");
|
|
2543
2585
|
return {
|
|
2544
2586
|
done: true,
|
|
2545
2587
|
response: {
|
|
2546
2588
|
code: 200,
|
|
2547
2589
|
payload: withDebugPayload({
|
|
2548
|
-
|
|
2549
|
-
|
|
2590
|
+
// `info`, not `applied`: nothing was applied either way, and the
|
|
2591
|
+
// approval-gate branch above has answered `info` for the same
|
|
2592
|
+
// fact since it was written.
|
|
2593
|
+
status: "info",
|
|
2594
|
+
summary: noopSummary,
|
|
2550
2595
|
changes: [],
|
|
2551
2596
|
mentionedSlugs: [effectiveSlug],
|
|
2552
2597
|
previewVersion: versions.get(body.session) ?? 0,
|
|
@@ -19,6 +19,11 @@ export type HallucinatedProp = {
|
|
|
19
19
|
export type HallucinationValidationResult = {
|
|
20
20
|
plan: EditPlan;
|
|
21
21
|
hallucinatedProps: HallucinatedProp[];
|
|
22
|
+
/**
|
|
23
|
+
* The user-facing note appended to `summary_for_user` and `change_log`,
|
|
24
|
+
* when props were stripped. Absent when nothing was.
|
|
25
|
+
*/
|
|
26
|
+
note?: string;
|
|
22
27
|
};
|
|
23
28
|
/**
|
|
24
29
|
* Scan every `update_props` op in the plan, strip patch keys that are not
|
|
@@ -105,6 +105,19 @@ export function validateAndStripHallucinatedProps(args) {
|
|
|
105
105
|
const summary = plan.summary_for_user?.trimEnd() ?? "";
|
|
106
106
|
plan.summary_for_user = summary.length > 0 ? `${summary}\n\n${note}` : note;
|
|
107
107
|
plan.change_log = [...plan.change_log, note];
|
|
108
|
+
/*
|
|
109
|
+
* Returned as well as appended, for two callers that both need it back.
|
|
110
|
+
*
|
|
111
|
+
* The changelog-coverage validator runs next and trims trailing entries
|
|
112
|
+
* when there are more of them than ops — and this note is always the last
|
|
113
|
+
* entry, so it was always the one trimmed. It has to be told which entry is
|
|
114
|
+
* a note rather than an op narration.
|
|
115
|
+
*
|
|
116
|
+
* And the no-effective-change handler replaces `summary_for_user` wholesale
|
|
117
|
+
* with a canned line, which is exactly the case where this note is the only
|
|
118
|
+
* true thing anyone wrote about the turn.
|
|
119
|
+
*/
|
|
120
|
+
return { plan, hallucinatedProps, note };
|
|
108
121
|
}
|
|
109
122
|
return { plan, hallucinatedProps };
|
|
110
123
|
}
|
package/dist/cms/adapter.d.ts
CHANGED
|
@@ -57,6 +57,41 @@ export type CmsPublishResult = void | {
|
|
|
57
57
|
error?: string;
|
|
58
58
|
unsupported?: string[];
|
|
59
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* One asset from a CMS's media library, in the shape the editor's image picker
|
|
62
|
+
* renders. Deliberately the smallest projection that a picker needs: an
|
|
63
|
+
* identity, something to show, something to insert, and something to put in an
|
|
64
|
+
* `alt`. Anything richer would be a per-CMS shape again.
|
|
65
|
+
*/
|
|
66
|
+
export interface CmsMediaItem {
|
|
67
|
+
/** Stable id in the source store. Used as the picker's selection key. */
|
|
68
|
+
id: string;
|
|
69
|
+
/** Human-facing filename or title, if the store has one. */
|
|
70
|
+
name?: string;
|
|
71
|
+
/** Full-size URL to insert into the page. Omitted only if the store has none. */
|
|
72
|
+
imageUrl?: string;
|
|
73
|
+
/** Grid thumbnail. May be the same URL as `imageUrl` when no derivative exists. */
|
|
74
|
+
thumbUrl: string;
|
|
75
|
+
/** Alt text the store already holds, so the editor does not invent one. */
|
|
76
|
+
alt?: string;
|
|
77
|
+
}
|
|
78
|
+
/** One page of a media-library search. */
|
|
79
|
+
export interface CmsMediaQuery {
|
|
80
|
+
/** Free-text filter. Empty or absent means "everything, newest first". */
|
|
81
|
+
query?: string;
|
|
82
|
+
/** 1-based page number. */
|
|
83
|
+
page: number;
|
|
84
|
+
/** Items per page. */
|
|
85
|
+
limit: number;
|
|
86
|
+
}
|
|
87
|
+
/** What `getMedia` returns: the page, and how many there are in total. */
|
|
88
|
+
export interface CmsMediaPage {
|
|
89
|
+
items: CmsMediaItem[];
|
|
90
|
+
/** Total pages for this query, so the picker knows whether to offer "load more". */
|
|
91
|
+
totalPages: number;
|
|
92
|
+
/** Display name for the tab, e.g. "Sanity". Defaults to the adapter's `id`. */
|
|
93
|
+
label?: string;
|
|
94
|
+
}
|
|
60
95
|
/**
|
|
61
96
|
* Which side of a CMS's draft/published split a read wants.
|
|
62
97
|
*
|
|
@@ -146,6 +181,24 @@ export interface CmsAdapter {
|
|
|
146
181
|
* unreachable, so it cannot be a method that reads upstream.
|
|
147
182
|
*/
|
|
148
183
|
readonly capabilities?: CmsCapabilities;
|
|
184
|
+
/**
|
|
185
|
+
* Optional media-library read, for the editor's image picker.
|
|
186
|
+
*
|
|
187
|
+
* The picker used to reach the CMS from the browser, over a closed union of
|
|
188
|
+
* three providers compiled into the editor app. That made the image picker
|
|
189
|
+
* the one part of an integration that could not be integrated: a site could
|
|
190
|
+
* bring its own adapter, its own blocks and its own publish handler, and
|
|
191
|
+
* still had no way to bring its own images short of a change to Avocado.
|
|
192
|
+
*
|
|
193
|
+
* Implement it and the tab appears. Leave it off and it does not — silence
|
|
194
|
+
* means no here, the same way it does for `perspectives`, because a picker
|
|
195
|
+
* that offers a tab it cannot fill is worse than no tab.
|
|
196
|
+
*
|
|
197
|
+
* `cmsMediaSource()` in `./media-sources.ts` implements this for Contentful,
|
|
198
|
+
* Sanity and Strapi if one of those is what you have; it is a helper, not a
|
|
199
|
+
* requirement, and nothing in the route path knows it exists.
|
|
200
|
+
*/
|
|
201
|
+
getMedia?(query: CmsMediaQuery): Promise<CmsMediaPage>;
|
|
149
202
|
}
|
|
150
203
|
/**
|
|
151
204
|
* Per-operation capability declaration. Every field is tri-state, and the
|
|
@@ -203,6 +256,15 @@ export interface ResolvedCapabilities {
|
|
|
203
256
|
* knows not to conclude "nothing is pending" from an empty diff.
|
|
204
257
|
*/
|
|
205
258
|
readsDraftPerspective: boolean;
|
|
259
|
+
/**
|
|
260
|
+
* True when the adapter can list the CMS's media library; derived from the
|
|
261
|
+
* presence of `getMedia`, never declared as a capability.
|
|
262
|
+
*
|
|
263
|
+
* The editor reads it to decide whether to offer a CMS tab in the image
|
|
264
|
+
* picker at all, which is why it is derived rather than declared: a tab that
|
|
265
|
+
* opens onto a method nobody implemented is a worse answer than no tab.
|
|
266
|
+
*/
|
|
267
|
+
readsMedia: boolean;
|
|
206
268
|
/**
|
|
207
269
|
* The subset the adapter actually declared. A caller that needs to
|
|
208
270
|
* distinguish "this site says no" from "nobody said" reads this; everything
|
package/dist/cms/adapter.js
CHANGED
|
@@ -14,6 +14,7 @@ export function resolveCapabilities(adapter, override) {
|
|
|
14
14
|
publishesUpstream: typeof adapter?.onPublish === "function",
|
|
15
15
|
// `=== true`, not truthiness: an adapter that says nothing has not said yes.
|
|
16
16
|
readsDraftPerspective: adapter?.perspectives === true,
|
|
17
|
+
readsMedia: typeof adapter?.getMedia === "function",
|
|
17
18
|
declared
|
|
18
19
|
};
|
|
19
20
|
}
|
package/dist/cms/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./adapter.ts";
|
|
1
|
+
export type { CmsAdapter, CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./adapter.ts";
|
|
2
2
|
export { resolveCapabilities } from "./adapter.ts";
|
|
3
3
|
export { jsonFileAdapter, type JsonFileAdapterOptions } from "./json-file-adapter.ts";
|
|
4
4
|
export { editorApiAdapter, type EditorApiAdapterOptions } from "./editor-api-adapter.ts";
|
|
5
5
|
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions, warmSessionBootstrap } from "./bootstrap.ts";
|
|
6
|
+
export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaSourceConfig } from "./media-sources.ts";
|
package/dist/cms/index.js
CHANGED
|
@@ -2,3 +2,4 @@ export { resolveCapabilities } from "./adapter.js";
|
|
|
2
2
|
export { jsonFileAdapter } from "./json-file-adapter.js";
|
|
3
3
|
export { editorApiAdapter } from "./editor-api-adapter.js";
|
|
4
4
|
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, warmSessionBootstrap } from "./bootstrap.js";
|
|
5
|
+
export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CmsMediaPage, CmsMediaQuery } from "./adapter.ts";
|
|
2
|
+
/** Connection details for one of the built-in media readers. */
|
|
3
|
+
export type CmsMediaSourceConfig = {
|
|
4
|
+
provider: "contentful";
|
|
5
|
+
spaceId: string;
|
|
6
|
+
deliveryToken: string;
|
|
7
|
+
environment?: string;
|
|
8
|
+
} | {
|
|
9
|
+
provider: "sanity";
|
|
10
|
+
projectId: string;
|
|
11
|
+
dataset?: string;
|
|
12
|
+
token?: string;
|
|
13
|
+
} | {
|
|
14
|
+
provider: "strapi";
|
|
15
|
+
url: string;
|
|
16
|
+
token?: string;
|
|
17
|
+
};
|
|
18
|
+
/** A media reader: the exact shape of `CmsAdapter.getMedia`. */
|
|
19
|
+
export type CmsMediaSource = (query: CmsMediaQuery) => Promise<CmsMediaPage>;
|
|
20
|
+
/** Display name for a provider id, for the picker's tab. */
|
|
21
|
+
export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]): string;
|
|
22
|
+
/**
|
|
23
|
+
* Build a media reader from connection details.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* export function sanityAdapter(): CmsAdapter {
|
|
27
|
+
* return {
|
|
28
|
+
* id: "sanity",
|
|
29
|
+
* getPages: …,
|
|
30
|
+
* getMedia: cmsMediaSource({ provider: "sanity", projectId, dataset })
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Every reader answers an empty page rather than throwing when the upstream
|
|
36
|
+
* call fails: a picker tab that renders nothing is recoverable, and a rejected
|
|
37
|
+
* promise inside a modal is not.
|
|
38
|
+
*/
|
|
39
|
+
export declare function cmsMediaSource(config: CmsMediaSourceConfig): CmsMediaSource;
|
|
40
|
+
/**
|
|
41
|
+
* Build a reader from an untrusted object, or `null` if it is not one of the
|
|
42
|
+
* three shapes.
|
|
43
|
+
*
|
|
44
|
+
* The editor sends this over the wire, so every field is checked rather than
|
|
45
|
+
* asserted. A partially-filled form — the drawer creates `{ provider:
|
|
46
|
+
* "sanity", projectId: "" }` the moment the provider is picked — must read as
|
|
47
|
+
* "not configured" and not as a reader that will 404 on every request.
|
|
48
|
+
*/
|
|
49
|
+
export declare function mediaSourceFromUnknown(raw: unknown): CmsMediaSource | null;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const LABELS = {
|
|
2
|
+
contentful: "Contentful",
|
|
3
|
+
sanity: "Sanity",
|
|
4
|
+
strapi: "Strapi"
|
|
5
|
+
};
|
|
6
|
+
/** Display name for a provider id, for the picker's tab. */
|
|
7
|
+
export function cmsMediaLabel(provider) {
|
|
8
|
+
return LABELS[provider];
|
|
9
|
+
}
|
|
10
|
+
const EMPTY = (label) => ({ items: [], totalPages: 0, label });
|
|
11
|
+
/**
|
|
12
|
+
* Build a media reader from connection details.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* export function sanityAdapter(): CmsAdapter {
|
|
16
|
+
* return {
|
|
17
|
+
* id: "sanity",
|
|
18
|
+
* getPages: …,
|
|
19
|
+
* getMedia: cmsMediaSource({ provider: "sanity", projectId, dataset })
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Every reader answers an empty page rather than throwing when the upstream
|
|
25
|
+
* call fails: a picker tab that renders nothing is recoverable, and a rejected
|
|
26
|
+
* promise inside a modal is not.
|
|
27
|
+
*/
|
|
28
|
+
export function cmsMediaSource(config) {
|
|
29
|
+
switch (config.provider) {
|
|
30
|
+
case "contentful": return (q) => contentfulMedia(config, q);
|
|
31
|
+
case "sanity": return (q) => sanityMedia(config, q);
|
|
32
|
+
case "strapi": return (q) => strapiMedia(config, q);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Contentful — Delivery API, `skip`/`limit` paging, `total` in the envelope.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
async function contentfulMedia(config, { query, page, limit }) {
|
|
39
|
+
const label = LABELS.contentful;
|
|
40
|
+
const env = config.environment ?? "master";
|
|
41
|
+
const params = new URLSearchParams({
|
|
42
|
+
access_token: config.deliveryToken,
|
|
43
|
+
limit: String(limit),
|
|
44
|
+
skip: String((page - 1) * limit),
|
|
45
|
+
mimetype_group: "image"
|
|
46
|
+
});
|
|
47
|
+
if (query)
|
|
48
|
+
params.set("query", query);
|
|
49
|
+
const res = await fetch(`https://cdn.contentful.com/spaces/${config.spaceId}/environments/${env}/assets?${params}`);
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
return EMPTY(label);
|
|
52
|
+
const data = (await res.json());
|
|
53
|
+
const items = (data.items ?? [])
|
|
54
|
+
.filter((item) => item.sys?.id && item.fields?.file?.url)
|
|
55
|
+
.map((item) => {
|
|
56
|
+
const url = item.fields.file.url;
|
|
57
|
+
// Contentful returns protocol-relative asset URLs.
|
|
58
|
+
const fullUrl = url.startsWith("//") ? `https:${url}` : url;
|
|
59
|
+
return {
|
|
60
|
+
id: item.sys.id,
|
|
61
|
+
name: item.fields?.title,
|
|
62
|
+
alt: item.fields?.description ?? item.fields?.title,
|
|
63
|
+
imageUrl: fullUrl,
|
|
64
|
+
thumbUrl: `${fullUrl}?w=200&h=200&fit=thumb`
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
return { items, totalPages: Math.ceil((data.total ?? 0) / limit), label };
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Sanity — GROQ, slice paging, a second query for the count.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
async function sanityMedia(config, { query, page, limit }) {
|
|
73
|
+
const label = LABELS.sanity;
|
|
74
|
+
const dataset = config.dataset ?? "production";
|
|
75
|
+
const offset = (page - 1) * limit;
|
|
76
|
+
const end = offset + limit - 1;
|
|
77
|
+
// GROQ is interpolated, so the filter term is stripped of the two characters
|
|
78
|
+
// that could close the string literal it lands inside.
|
|
79
|
+
const safeQuery = (query ?? "").replace(/["\\]/g, "");
|
|
80
|
+
const filter = safeQuery
|
|
81
|
+
? `_type == "sanity.imageAsset" && originalFilename match "*${safeQuery}*"`
|
|
82
|
+
: `_type == "sanity.imageAsset"`;
|
|
83
|
+
const groq = `*[${filter}] | order(_createdAt desc) [${offset}..${end}] { _id, url, originalFilename, metadata { dimensions } }`;
|
|
84
|
+
const countGroq = `count(*[${filter}])`;
|
|
85
|
+
const base = `https://${config.projectId}.api.sanity.io/v2024-01-01/data/query/${dataset}`;
|
|
86
|
+
const headers = {};
|
|
87
|
+
if (config.token)
|
|
88
|
+
headers.authorization = `Bearer ${config.token}`;
|
|
89
|
+
const [assetsRes, countRes] = await Promise.all([
|
|
90
|
+
fetch(`${base}?query=${encodeURIComponent(groq)}`, { headers }),
|
|
91
|
+
fetch(`${base}?query=${encodeURIComponent(countGroq)}`, { headers })
|
|
92
|
+
]);
|
|
93
|
+
if (!assetsRes.ok)
|
|
94
|
+
return EMPTY(label);
|
|
95
|
+
const assets = (await assetsRes.json());
|
|
96
|
+
const count = countRes.ok ? (await countRes.json()).result ?? 0 : 0;
|
|
97
|
+
return {
|
|
98
|
+
items: (assets.result ?? []).map((asset) => ({
|
|
99
|
+
id: asset._id,
|
|
100
|
+
name: asset.originalFilename,
|
|
101
|
+
alt: asset.originalFilename,
|
|
102
|
+
imageUrl: asset.url,
|
|
103
|
+
thumbUrl: `${asset.url}?w=200&h=200&fit=crop`
|
|
104
|
+
})),
|
|
105
|
+
totalPages: Math.ceil(count / limit),
|
|
106
|
+
label
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Strapi — upload plugin, page/pageSize paging, count in a response header.
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
async function strapiMedia(config, { query, page, limit }) {
|
|
113
|
+
const label = LABELS.strapi;
|
|
114
|
+
const baseUrl = config.url.replace(/\/+$/, "");
|
|
115
|
+
const params = new URLSearchParams({
|
|
116
|
+
"filters[mime][$startsWith]": "image",
|
|
117
|
+
"pagination[page]": String(page),
|
|
118
|
+
"pagination[pageSize]": String(limit),
|
|
119
|
+
sort: "createdAt:desc"
|
|
120
|
+
});
|
|
121
|
+
if (query)
|
|
122
|
+
params.set("filters[name][$containsi]", query);
|
|
123
|
+
const headers = {};
|
|
124
|
+
if (config.token)
|
|
125
|
+
headers.authorization = `Bearer ${config.token}`;
|
|
126
|
+
const res = await fetch(`${baseUrl}/api/upload/files?${params}`, { headers });
|
|
127
|
+
if (!res.ok)
|
|
128
|
+
return EMPTY(label);
|
|
129
|
+
const data = (await res.json());
|
|
130
|
+
const absolute = (u) => (u.startsWith("http") ? u : `${baseUrl}${u}`);
|
|
131
|
+
// The upload endpoint returns a bare array; the count is a header.
|
|
132
|
+
const totalCount = Number(res.headers.get("x-total-count") ?? data.length);
|
|
133
|
+
return {
|
|
134
|
+
items: data.map((file) => ({
|
|
135
|
+
id: file.documentId ?? String(file.id),
|
|
136
|
+
name: file.name,
|
|
137
|
+
alt: file.name,
|
|
138
|
+
imageUrl: absolute(file.url),
|
|
139
|
+
thumbUrl: file.formats?.thumbnail?.url ? absolute(file.formats.thumbnail.url) : absolute(file.url)
|
|
140
|
+
})),
|
|
141
|
+
totalPages: Math.ceil(totalCount / limit),
|
|
142
|
+
label
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Build a reader from an untrusted object, or `null` if it is not one of the
|
|
147
|
+
* three shapes.
|
|
148
|
+
*
|
|
149
|
+
* The editor sends this over the wire, so every field is checked rather than
|
|
150
|
+
* asserted. A partially-filled form — the drawer creates `{ provider:
|
|
151
|
+
* "sanity", projectId: "" }` the moment the provider is picked — must read as
|
|
152
|
+
* "not configured" and not as a reader that will 404 on every request.
|
|
153
|
+
*/
|
|
154
|
+
export function mediaSourceFromUnknown(raw) {
|
|
155
|
+
if (!raw || typeof raw !== "object")
|
|
156
|
+
return null;
|
|
157
|
+
const obj = raw;
|
|
158
|
+
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
159
|
+
switch (obj.provider) {
|
|
160
|
+
case "contentful": {
|
|
161
|
+
const spaceId = str(obj.spaceId);
|
|
162
|
+
const deliveryToken = str(obj.deliveryToken);
|
|
163
|
+
if (!spaceId || !deliveryToken)
|
|
164
|
+
return null;
|
|
165
|
+
return cmsMediaSource({ provider: "contentful", spaceId, deliveryToken, environment: str(obj.environment) });
|
|
166
|
+
}
|
|
167
|
+
case "sanity": {
|
|
168
|
+
const projectId = str(obj.projectId);
|
|
169
|
+
if (!projectId)
|
|
170
|
+
return null;
|
|
171
|
+
return cmsMediaSource({ provider: "sanity", projectId, dataset: str(obj.dataset), token: str(obj.token) });
|
|
172
|
+
}
|
|
173
|
+
case "strapi": {
|
|
174
|
+
const url = str(obj.url);
|
|
175
|
+
if (!url)
|
|
176
|
+
return null;
|
|
177
|
+
return cmsMediaSource({ provider: "strapi", url, token: str(obj.token) });
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
package/dist/handler/auth.js
CHANGED
|
@@ -29,7 +29,13 @@ import { isAccessGateEnabled, isValidAccessToken, extractAccessToken } from "../
|
|
|
29
29
|
* gating it would blank every uploaded image in the live site, not just in the
|
|
30
30
|
* editor. Both are read-only and neither reveals session content.
|
|
31
31
|
*/
|
|
32
|
-
|
|
32
|
+
/*
|
|
33
|
+
* `/health` is here because a probe that needs a credential is not a probe. The
|
|
34
|
+
* CLI calls it before it has one, to compare protocol versions, and it answers
|
|
35
|
+
* nothing about the site's content — only that this is an Avocado orchestrator
|
|
36
|
+
* and which protocol it speaks.
|
|
37
|
+
*/
|
|
38
|
+
const PUBLIC_PATHS = new Set(["/auth/status", "/auth/verify", "/health"]);
|
|
33
39
|
export function isPublicPath(path, method) {
|
|
34
40
|
if (method === "OPTIONS")
|
|
35
41
|
return true;
|
|
@@ -21,7 +21,7 @@ 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 } from "@avocadostudio-ai/shared";
|
|
24
|
+
import { operationSchema, blockManifestSchema, siteConfigSchema, declareBlockCatalogue, undeclaredBlockTypes, EDITOR_PROTOCOL_VERSION } from "@avocadostudio-ai/shared";
|
|
25
25
|
import { chatRequestBodySchema } from "../nlp/intent-detection.js";
|
|
26
26
|
import { applyOpsAtomically, pickFocusBlockId, pickUpdatedSlug, toErrorDetail, classifyGuardrailError } from "../ops/ops-engine.js";
|
|
27
27
|
import { runChatStream, formatSseFrame } from "../http/chat-stream.js";
|
|
@@ -47,6 +47,7 @@ import { createFeedbackStore } from "../telemetry/feedback-store.js";
|
|
|
47
47
|
import { consoleLogger } from "../logger.js";
|
|
48
48
|
import { createCmsBootstrapCache } from "../cms/bootstrap.js";
|
|
49
49
|
import { resolveCapabilities } from "../cms/adapter.js";
|
|
50
|
+
import { mediaSourceFromUnknown } from "../cms/media-sources.js";
|
|
50
51
|
import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
|
|
51
52
|
import { checkAuth, resolveAuth } from "./auth.js";
|
|
52
53
|
const defaultModelLookup = () => ({
|
|
@@ -196,6 +197,7 @@ function jsonResponse(body, init = {}) {
|
|
|
196
197
|
* only thing that keeps a list like this honest.
|
|
197
198
|
*/
|
|
198
199
|
const SUPPORTED_ROUTES = [
|
|
200
|
+
"GET /health",
|
|
199
201
|
"GET /auth/status",
|
|
200
202
|
"POST /auth/verify",
|
|
201
203
|
"POST /chat",
|
|
@@ -228,6 +230,7 @@ const SUPPORTED_ROUTES = [
|
|
|
228
230
|
"POST /restore/snapshot",
|
|
229
231
|
"DELETE /restore/snapshot",
|
|
230
232
|
"GET /unsplash/search",
|
|
233
|
+
"POST /media/cms",
|
|
231
234
|
"GET+POST /telemetry/chat/feedback",
|
|
232
235
|
"POST /preview/screenshot",
|
|
233
236
|
"POST /audio/transcribe",
|
|
@@ -834,6 +837,10 @@ export function createOrchestrator(config = {}) {
|
|
|
834
837
|
unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
|
|
835
838
|
imageGenerate: hasImageBackend,
|
|
836
839
|
imageGenerateChat: hasImageBackend,
|
|
840
|
+
/* Whether this mount's adapter can list a media library. The
|
|
841
|
+
* editor offers the picker's CMS tab on it, so silence hides the
|
|
842
|
+
* tab rather than opening one onto a method nobody implemented. */
|
|
843
|
+
cmsMedia: runtime.capabilities.readsMedia,
|
|
837
844
|
agentMode: false
|
|
838
845
|
},
|
|
839
846
|
/*
|
|
@@ -899,7 +906,9 @@ export function createOrchestrator(config = {}) {
|
|
|
899
906
|
})),
|
|
900
907
|
// Which content this is — see orchestrator-core/http/draft-provenance.ts.
|
|
901
908
|
...describeDraft({
|
|
902
|
-
|
|
909
|
+
// Config first, matching every other route: a mount that names its
|
|
910
|
+
// site knows better than a request that forgot to.
|
|
911
|
+
requestedSiteId: effectiveSiteId ?? siteId,
|
|
903
912
|
scopedSession,
|
|
904
913
|
pageCount: pages.length,
|
|
905
914
|
hasAdapter: Boolean(runtime.adapter)
|
|
@@ -1243,12 +1252,38 @@ export function createOrchestrator(config = {}) {
|
|
|
1243
1252
|
}
|
|
1244
1253
|
return actionResponse(blocksManifestAction(), cors);
|
|
1245
1254
|
}
|
|
1255
|
+
/*
|
|
1256
|
+
* Liveness and protocol version, for a client deciding whether it can talk
|
|
1257
|
+
* to this build at all.
|
|
1258
|
+
*
|
|
1259
|
+
* The standalone orchestrator has always answered this; library mode never
|
|
1260
|
+
* did, so `avocado-cli start` probed it, got a 405, and printed
|
|
1261
|
+
* "Orchestrator /health returned 405. CLI started anyway." on every start —
|
|
1262
|
+
* a warning that means nothing, from the CLI's only compatibility check,
|
|
1263
|
+
* which could therefore never pass against an embedded mount.
|
|
1264
|
+
*
|
|
1265
|
+
* Deliberately before the auth gate (see `PUBLIC_PATHS`) and deliberately
|
|
1266
|
+
* content-free: it says what this process is, never what it holds.
|
|
1267
|
+
*/
|
|
1268
|
+
if (request.method === "GET" && path === "/health") {
|
|
1269
|
+
return jsonResponse({ ok: true, mode: "library", protocolVersion: EDITOR_PROTOCOL_VERSION }, { cors });
|
|
1270
|
+
}
|
|
1246
1271
|
if (request.method === "GET" && path === "/whoami") {
|
|
1247
1272
|
const runtime = await getRuntime();
|
|
1248
1273
|
await runtime.ready;
|
|
1249
1274
|
const query = Object.fromEntries(url.searchParams);
|
|
1250
1275
|
await runtime.bootstrapCache.ensure(scope(query.session, query.siteId), runtime.adapter, runtime.log);
|
|
1251
|
-
|
|
1276
|
+
/*
|
|
1277
|
+
* `scope()` resolves the site the mount serves; `whoamiAction` re-derives
|
|
1278
|
+
* the session key from what it is handed. Handing it the raw query meant
|
|
1279
|
+
* the two disagreed whenever the caller omitted `siteId` — the bootstrap
|
|
1280
|
+
* seeded `library::default` and the answer was read from the legacy key,
|
|
1281
|
+
* so a correctly configured mount reported Avocado's bundled demo
|
|
1282
|
+
* content, `source: "demo"`, and all-true capabilities in place of the
|
|
1283
|
+
* adapter's own. The note even said "No siteId was supplied", which was
|
|
1284
|
+
* true of the request and false of the configuration.
|
|
1285
|
+
*/
|
|
1286
|
+
return actionResponse(whoamiAction({ ...query, siteId: effectiveSiteId ?? query.siteId }, url.origin, { hasAdapter: Boolean(runtime.adapter) }), cors);
|
|
1252
1287
|
}
|
|
1253
1288
|
/*
|
|
1254
1289
|
* Publishing status, history and preview.
|
|
@@ -1360,6 +1395,53 @@ export function createOrchestrator(config = {}) {
|
|
|
1360
1395
|
}
|
|
1361
1396
|
return actionResponse(await restoreSnapshotDelete((raw ?? {})), cors);
|
|
1362
1397
|
}
|
|
1398
|
+
/*
|
|
1399
|
+
* The CMS tab of the image picker.
|
|
1400
|
+
*
|
|
1401
|
+
* Two callers, one route. A library-mode site whose adapter implements
|
|
1402
|
+
* `getMedia` needs to send nothing: the credentials are already in the
|
|
1403
|
+
* process serving this request. The standalone multi-site orchestrator
|
|
1404
|
+
* wires no adapter at all, so the editor sends the per-site connection
|
|
1405
|
+
* details it holds and the route builds a reader from them.
|
|
1406
|
+
*
|
|
1407
|
+
* The adapter wins when both are available — a site that implemented the
|
|
1408
|
+
* seam meant it, and its own reader can see things a generic one cannot.
|
|
1409
|
+
*
|
|
1410
|
+
* POST rather than GET because the second form carries a token, and a
|
|
1411
|
+
* token in a query string is a token in every access log between here and
|
|
1412
|
+
* the browser.
|
|
1413
|
+
*/
|
|
1414
|
+
if (request.method === "POST" && path === "/media/cms") {
|
|
1415
|
+
const runtime = await getRuntime();
|
|
1416
|
+
await runtime.ready;
|
|
1417
|
+
let raw;
|
|
1418
|
+
try {
|
|
1419
|
+
raw = await request.json();
|
|
1420
|
+
}
|
|
1421
|
+
catch {
|
|
1422
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
1423
|
+
}
|
|
1424
|
+
const body = (raw ?? {});
|
|
1425
|
+
const query = typeof body.query === "string" ? body.query.trim() : "";
|
|
1426
|
+
const page = Math.max(1, Math.trunc(Number(body.page) || 1));
|
|
1427
|
+
const limit = Math.min(50, Math.max(1, Math.trunc(Number(body.limit) || 20)));
|
|
1428
|
+
const source = typeof runtime.adapter?.getMedia === "function"
|
|
1429
|
+
? runtime.adapter.getMedia.bind(runtime.adapter)
|
|
1430
|
+
: mediaSourceFromUnknown(body.config);
|
|
1431
|
+
/* No adapter method and no usable config is "this site has no media
|
|
1432
|
+
* library", which is a 404 and not an empty page — the editor hides the
|
|
1433
|
+
* tab on the former and renders an empty grid on the latter. */
|
|
1434
|
+
if (!source)
|
|
1435
|
+
return jsonResponse({ error: "CMS media not configured" }, { status: 404, cors });
|
|
1436
|
+
try {
|
|
1437
|
+
const result = await source({ query: query || undefined, page, limit });
|
|
1438
|
+
return jsonResponse(result, { cors });
|
|
1439
|
+
}
|
|
1440
|
+
catch (error) {
|
|
1441
|
+
runtime.log.warn?.({ err: error }, "[avocado] CMS media read failed");
|
|
1442
|
+
return jsonResponse({ items: [], totalPages: 0 }, { cors });
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1363
1445
|
/* The Unsplash tab of the image picker. Unconfigured answers 404, not an
|
|
1364
1446
|
* empty result set — "no key" and "no matches" are different answers. */
|
|
1365
1447
|
if (request.method === "GET" && path === "/unsplash/search") {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHandler } from "./handler/create-orchestrator.ts";
|
|
2
2
|
export type { OrchestratorAuth, AuthContext } from "./handler/auth.ts";
|
|
3
|
-
export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./cms/adapter.ts";
|
|
4
|
-
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, type JsonFileAdapterOptions, type EditorApiAdapterOptions } from "./cms/index.ts";
|
|
3
|
+
export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, ResolvedCapabilities } from "./cms/adapter.ts";
|
|
4
|
+
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaSourceConfig } from "./cms/index.ts";
|
package/dist/index.js
CHANGED
|
@@ -18,4 +18,4 @@
|
|
|
18
18
|
//
|
|
19
19
|
// The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
|
|
20
20
|
export { createOrchestrator } from "./handler/create-orchestrator.js";
|
|
21
|
-
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "./cms/index.js";
|
|
21
|
+
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "./cms/index.js";
|
package/dist/ops/ops-engine.d.ts
CHANGED
|
@@ -4,6 +4,23 @@ import type { ContentSource } from "../state/content-source.ts";
|
|
|
4
4
|
import type { PublishDiff } from "@avocadostudio-ai/shared";
|
|
5
5
|
export declare const toErrorDetail: typeof _unifiedToErrorDetail;
|
|
6
6
|
export declare function isNoEffectiveChangeError(reason: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Within a no-effective-change, did the stored values already match?
|
|
9
|
+
*
|
|
10
|
+
* The engine throws two different things and they are not the same fact:
|
|
11
|
+
*
|
|
12
|
+
* "No effective prop change across plan. All update patches matched
|
|
13
|
+
* existing values." → the content really was already what was asked for
|
|
14
|
+
* "Edit plan produced no changes"
|
|
15
|
+
* → the plan had nothing left to apply, which is what
|
|
16
|
+
* happens when every prop in it was stripped as
|
|
17
|
+
* unsupported by the block
|
|
18
|
+
*
|
|
19
|
+
* Reporting the second as the first tells the author their content is up to
|
|
20
|
+
* date when their request was in fact dropped. Both messages are pinned by
|
|
21
|
+
* `isNoEffectiveChangeError` above, so they are already load-bearing strings.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isAlreadyCurrentError(reason: string): boolean;
|
|
7
24
|
export declare function classifyGuardrailError(reason: string): GuardrailErrorCategory;
|
|
8
25
|
export declare function formatValidationError(reason: string): string;
|
|
9
26
|
export declare function isDeterministicRepairEligible(reason: string): boolean;
|
package/dist/ops/ops-engine.js
CHANGED
|
@@ -159,6 +159,25 @@ export function isNoEffectiveChangeError(reason) {
|
|
|
159
159
|
// "No effective meta change for…" / "Edit plan produced no changes"
|
|
160
160
|
return /no effective \w+ change/i.test(reason) || /produced no changes/i.test(reason);
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Within a no-effective-change, did the stored values already match?
|
|
164
|
+
*
|
|
165
|
+
* The engine throws two different things and they are not the same fact:
|
|
166
|
+
*
|
|
167
|
+
* "No effective prop change across plan. All update patches matched
|
|
168
|
+
* existing values." → the content really was already what was asked for
|
|
169
|
+
* "Edit plan produced no changes"
|
|
170
|
+
* → the plan had nothing left to apply, which is what
|
|
171
|
+
* happens when every prop in it was stripped as
|
|
172
|
+
* unsupported by the block
|
|
173
|
+
*
|
|
174
|
+
* Reporting the second as the first tells the author their content is up to
|
|
175
|
+
* date when their request was in fact dropped. Both messages are pinned by
|
|
176
|
+
* `isNoEffectiveChangeError` above, so they are already load-bearing strings.
|
|
177
|
+
*/
|
|
178
|
+
export function isAlreadyCurrentError(reason) {
|
|
179
|
+
return /no effective \w+ change/i.test(reason);
|
|
180
|
+
}
|
|
162
181
|
export function classifyGuardrailError(reason) {
|
|
163
182
|
const lower = reason.toLowerCase();
|
|
164
183
|
if (isNoEffectiveChangeError(reason))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/orchestrator-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"openai": "^4.87.1",
|
|
24
24
|
"sharp": "^0.34.5",
|
|
25
25
|
"zod": "^4.3.6",
|
|
26
|
-
"@avocadostudio-ai/migration-sdk": "0.
|
|
27
|
-
"@avocadostudio-ai/shared": "0.
|
|
26
|
+
"@avocadostudio-ai/migration-sdk": "^0.3.0",
|
|
27
|
+
"@avocadostudio-ai/shared": "^0.3.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@google/genai": "^1.46.0",
|
|
@@ -44,6 +44,17 @@
|
|
|
44
44
|
"dist"
|
|
45
45
|
],
|
|
46
46
|
"description": "Core Avocado Studio orchestrator — session state, AI planning, operations engine, publishing",
|
|
47
|
+
"keywords": [
|
|
48
|
+
"avocado",
|
|
49
|
+
"avocado-studio",
|
|
50
|
+
"cms",
|
|
51
|
+
"ai",
|
|
52
|
+
"llm",
|
|
53
|
+
"content-editor",
|
|
54
|
+
"page-builder",
|
|
55
|
+
"headless-cms",
|
|
56
|
+
"visual-editing"
|
|
57
|
+
],
|
|
47
58
|
"license": "Apache-2.0",
|
|
48
59
|
"homepage": "https://github.com/avocadostudio-ai/avocado/tree/main/packages/orchestrator-core#readme",
|
|
49
60
|
"bugs": {
|