@avocadostudio-ai/orchestrator-core 0.2.3 → 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/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/create-orchestrator.js +53 -0
- 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 +3 -3
|
@@ -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
|
+
}
|
|
@@ -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 = () => ({
|
|
@@ -229,6 +230,7 @@ const SUPPORTED_ROUTES = [
|
|
|
229
230
|
"POST /restore/snapshot",
|
|
230
231
|
"DELETE /restore/snapshot",
|
|
231
232
|
"GET /unsplash/search",
|
|
233
|
+
"POST /media/cms",
|
|
232
234
|
"GET+POST /telemetry/chat/feedback",
|
|
233
235
|
"POST /preview/screenshot",
|
|
234
236
|
"POST /audio/transcribe",
|
|
@@ -835,6 +837,10 @@ export function createOrchestrator(config = {}) {
|
|
|
835
837
|
unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
|
|
836
838
|
imageGenerate: hasImageBackend,
|
|
837
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,
|
|
838
844
|
agentMode: false
|
|
839
845
|
},
|
|
840
846
|
/*
|
|
@@ -1389,6 +1395,53 @@ export function createOrchestrator(config = {}) {
|
|
|
1389
1395
|
}
|
|
1390
1396
|
return actionResponse(await restoreSnapshotDelete((raw ?? {})), cors);
|
|
1391
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
|
+
}
|
|
1392
1445
|
/* The Unsplash tab of the image picker. Unconfigured answers 404, not an
|
|
1393
1446
|
* empty result set — "no key" and "no matches" are different answers. */
|
|
1394
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",
|