@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.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/agent/sites-agent-context.js +3 -2
- package/dist/agent/sites-agent-shared.js +1 -0
- package/dist/chat/anthropic-planner.js +3 -3
- package/dist/chat/chat-pipeline.js +122 -20
- package/dist/chat/gemini-planner.js +3 -3
- package/dist/chat/planner.js +7 -5
- package/dist/chat/prompts.js +6 -1
- package/dist/cms/adapter.d.ts +159 -1
- package/dist/cms/adapter.js +19 -1
- package/dist/cms/bootstrap.d.ts +46 -1
- package/dist/cms/bootstrap.js +126 -2
- package/dist/cms/index.d.ts +3 -2
- package/dist/cms/index.js +2 -1
- package/dist/errors.d.ts +9 -1
- package/dist/handler/auth.d.ts +79 -0
- package/dist/handler/auth.js +113 -0
- package/dist/handler/create-orchestrator.d.ts +205 -0
- package/dist/handler/create-orchestrator.js +1599 -0
- package/dist/http/access-tokens.d.ts +58 -0
- package/dist/http/access-tokens.js +161 -0
- package/dist/http/audio-actions.d.ts +121 -0
- package/dist/http/audio-actions.js +248 -0
- package/dist/http/blocks-actions.d.ts +31 -0
- package/dist/http/blocks-actions.js +31 -0
- package/dist/http/draft-provenance.d.ts +68 -0
- package/dist/http/draft-provenance.js +101 -0
- package/dist/http/history-actions.d.ts +58 -0
- package/dist/http/history-actions.js +169 -0
- package/dist/http/image-generate-actions.d.ts +268 -0
- package/dist/http/image-generate-actions.js +546 -0
- package/dist/http/ops-actions.d.ts +51 -0
- package/dist/http/ops-actions.js +79 -0
- package/dist/http/publish-actions.d.ts +153 -0
- package/dist/http/publish-actions.js +323 -0
- package/dist/http/restore-actions.d.ts +67 -0
- package/dist/http/restore-actions.js +145 -0
- package/dist/http/screenshot-actions.d.ts +108 -0
- package/dist/http/screenshot-actions.js +181 -0
- package/dist/http/session-actions.d.ts +35 -0
- package/dist/http/session-actions.js +98 -0
- package/dist/http/telemetry-feedback-actions.d.ts +53 -0
- package/dist/http/telemetry-feedback-actions.js +68 -0
- package/dist/http/unsplash-actions.d.ts +64 -0
- package/dist/http/unsplash-actions.js +81 -0
- package/dist/http/variations-actions.d.ts +102 -0
- package/dist/http/variations-actions.js +104 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +21 -1
- package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
- package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
- package/dist/nlp/deterministic-planner-suggestions.js +37 -11
- package/dist/nlp/plan-normalizer.js +18 -2
- package/dist/ops/ops-engine.js +219 -14
- package/dist/state/session-state.d.ts +56 -1
- package/dist/state/session-state.js +92 -6
- package/dist/state/sqlite-store-singleton.d.ts +22 -0
- package/dist/state/sqlite-store-singleton.js +49 -1
- package/dist/state/sqlite-store.d.ts +5 -0
- package/dist/state/sqlite-store.js +125 -2
- package/dist/telemetry/chat-telemetry.js +6 -1
- package/package.json +12 -16
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thumbs-up / thumbs-down on a chat trace, as transport-agnostic actions.
|
|
3
|
+
*
|
|
4
|
+
* `POST /telemetry/chat/feedback` was written inline in
|
|
5
|
+
* `apps/orchestrator/src/index.ts` rather than in a route plugin, so library
|
|
6
|
+
* mode — `createOrchestrator()` in the site SDK, which reimplements by hand
|
|
7
|
+
* whichever routes somebody remembered to add — never got it. The editor sends
|
|
8
|
+
* the rating best-effort and ignores the response, so on a library-mode site
|
|
9
|
+
* every thumb the user clicked was answered "not handled by
|
|
10
|
+
* createOrchestrator()" and silently thrown away: the UI kept showing the
|
|
11
|
+
* button as accepted and no signal ever reached the store.
|
|
12
|
+
*
|
|
13
|
+
* A second hand-kept copy would drift the way the first one did, so the
|
|
14
|
+
* validation lives here and both transports call it. Each function returns the
|
|
15
|
+
* status code and body to send; neither Fastify nor `Response` appears in this
|
|
16
|
+
* file.
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
const badRequest = (error) => ({ code: 400, body: { error } });
|
|
20
|
+
/** Free-text notes are stored verbatim in an append-only file, so cap what one row can cost. */
|
|
21
|
+
const NOTE_MAX_CHARS = 2000;
|
|
22
|
+
/**
|
|
23
|
+
* Record one rating against a chat trace.
|
|
24
|
+
*
|
|
25
|
+
* The rating is compared against the two literals rather than merely checked
|
|
26
|
+
* for being a string: the store's readers filter and count by rating, and a
|
|
27
|
+
* third value would quietly split the totals. An unusable payload is rejected
|
|
28
|
+
* rather than stored partially, because the editor fires this and forgets it —
|
|
29
|
+
* a row nobody can attribute to a trace is worse than no row.
|
|
30
|
+
*/
|
|
31
|
+
export function telemetryFeedbackSubmitAction(raw, store) {
|
|
32
|
+
const body = (raw ?? {});
|
|
33
|
+
const traceId = typeof body.traceId === "string" ? body.traceId.trim() : "";
|
|
34
|
+
const session = typeof body.session === "string" ? body.session.trim() : "";
|
|
35
|
+
const rating = body.rating === "up" || body.rating === "down" ? body.rating : null;
|
|
36
|
+
const note = typeof body.note === "string" ? body.note.trim().slice(0, NOTE_MAX_CHARS) : undefined;
|
|
37
|
+
if (!traceId || !session || !rating) {
|
|
38
|
+
return badRequest("traceId, session, and rating ('up' | 'down') are required");
|
|
39
|
+
}
|
|
40
|
+
const entry = {
|
|
41
|
+
id: randomUUID(),
|
|
42
|
+
at: new Date().toISOString(),
|
|
43
|
+
traceId,
|
|
44
|
+
session,
|
|
45
|
+
rating,
|
|
46
|
+
...(note ? { note } : {})
|
|
47
|
+
};
|
|
48
|
+
store.push(entry);
|
|
49
|
+
return { code: 200, body: { ok: true, id: entry.id } };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Read the ratings back, filtered.
|
|
53
|
+
*
|
|
54
|
+
* `limit` arrives as a query string over HTTP and as a number from anything
|
|
55
|
+
* calling this directly, so it is coerced here; the store clamps the range.
|
|
56
|
+
* Left absent rather than defaulted so the store's own default still applies.
|
|
57
|
+
*/
|
|
58
|
+
export function telemetryFeedbackListAction(query, store) {
|
|
59
|
+
return {
|
|
60
|
+
code: 200,
|
|
61
|
+
body: store.list({
|
|
62
|
+
session: query.session,
|
|
63
|
+
rating: query.rating,
|
|
64
|
+
traceId: query.traceId,
|
|
65
|
+
limit: query.limit !== undefined ? Number(query.limit) : undefined
|
|
66
|
+
})
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Unsplash search proxy behind the editor's image picker, as a
|
|
3
|
+
* transport-agnostic action.
|
|
4
|
+
*
|
|
5
|
+
* It lived only in `apps/orchestrator/src/routes/media.ts`, wired straight into
|
|
6
|
+
* Fastify. Library mode — `createOrchestrator()` in the site SDK — serves the
|
|
7
|
+
* same editor from a plain `Request`/`Response` handler and reimplements
|
|
8
|
+
* whichever routes somebody remembered to add. It never had this one, so the
|
|
9
|
+
* picker's Unsplash tab, which `/status/planner` advertises from the very same
|
|
10
|
+
* `UNSPLASH_ACCESS_KEY`, rendered and then answered "not handled by
|
|
11
|
+
* createOrchestrator()".
|
|
12
|
+
*
|
|
13
|
+
* The route is entirely session-free: no SQLite, no runtime, nothing from
|
|
14
|
+
* `session-state.js`. That is why it can be dispatched before a host's
|
|
15
|
+
* orchestrator runtime is ready, and why this file needs no collaborators
|
|
16
|
+
* beyond an access key and a `fetch`.
|
|
17
|
+
*/
|
|
18
|
+
import type { Logger } from "../logger.js";
|
|
19
|
+
import type { ActionResult } from "./history-actions.js";
|
|
20
|
+
export type { ActionResult };
|
|
21
|
+
/**
|
|
22
|
+
* Raw query values, straight off the wire.
|
|
23
|
+
*
|
|
24
|
+
* Fastify hands these over as strings; `Object.fromEntries(url.searchParams)`
|
|
25
|
+
* does the same. Numbers are accepted too so a direct caller (a test, an MCP
|
|
26
|
+
* tool) doesn't have to stringify first.
|
|
27
|
+
*/
|
|
28
|
+
export type UnsplashSearchQuery = {
|
|
29
|
+
q?: string;
|
|
30
|
+
limit?: string | number;
|
|
31
|
+
page?: string | number;
|
|
32
|
+
};
|
|
33
|
+
export type UnsplashSearchDeps = {
|
|
34
|
+
/** Overrides `process.env.UNSPLASH_ACCESS_KEY`. An empty string means "not configured". */
|
|
35
|
+
accessKey?: string;
|
|
36
|
+
/** Seam for tests: the whole point of this action is that it can be exercised without the network. */
|
|
37
|
+
fetchImpl?: typeof fetch;
|
|
38
|
+
log?: Logger;
|
|
39
|
+
};
|
|
40
|
+
/** One picker row. `thumbUrl` falls back to the full image when Unsplash omits the small variant. */
|
|
41
|
+
export type UnsplashSearchItem = {
|
|
42
|
+
id: string;
|
|
43
|
+
imageUrl: string;
|
|
44
|
+
thumbUrl: string;
|
|
45
|
+
alt: string;
|
|
46
|
+
author: string;
|
|
47
|
+
};
|
|
48
|
+
export type UnsplashSearchBody = {
|
|
49
|
+
items: UnsplashSearchItem[];
|
|
50
|
+
totalPages: number;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Search Unsplash for picker candidates.
|
|
54
|
+
*
|
|
55
|
+
* Every failure that isn't "no key at all" answers 200 with an empty result
|
|
56
|
+
* set. That is deliberately preserved: both clients — the editor's
|
|
57
|
+
* ImagePickerModal and the immersive widget's — read `{ items, totalPages }`
|
|
58
|
+
* off a successful response and treat a non-ok status as a hard error banner,
|
|
59
|
+
* so promoting an upstream 403 to a 5xx here would turn a rate limit into a
|
|
60
|
+
* broken tab. The cost is that a revoked key looks exactly like "no photos
|
|
61
|
+
* match", which is invisible in both transports — hence the `log.warn`, the one
|
|
62
|
+
* thing this action adds over the Fastify original.
|
|
63
|
+
*/
|
|
64
|
+
export declare function unsplashSearchAction(query: UnsplashSearchQuery, deps?: UnsplashSearchDeps): Promise<ActionResult>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Unsplash search proxy behind the editor's image picker, as a
|
|
3
|
+
* transport-agnostic action.
|
|
4
|
+
*
|
|
5
|
+
* It lived only in `apps/orchestrator/src/routes/media.ts`, wired straight into
|
|
6
|
+
* Fastify. Library mode — `createOrchestrator()` in the site SDK — serves the
|
|
7
|
+
* same editor from a plain `Request`/`Response` handler and reimplements
|
|
8
|
+
* whichever routes somebody remembered to add. It never had this one, so the
|
|
9
|
+
* picker's Unsplash tab, which `/status/planner` advertises from the very same
|
|
10
|
+
* `UNSPLASH_ACCESS_KEY`, rendered and then answered "not handled by
|
|
11
|
+
* createOrchestrator()".
|
|
12
|
+
*
|
|
13
|
+
* The route is entirely session-free: no SQLite, no runtime, nothing from
|
|
14
|
+
* `session-state.js`. That is why it can be dispatched before a host's
|
|
15
|
+
* orchestrator runtime is ready, and why this file needs no collaborators
|
|
16
|
+
* beyond an access key and a `fetch`.
|
|
17
|
+
*/
|
|
18
|
+
// A fresh object each time: a single shared literal would let one caller's
|
|
19
|
+
// mutation of the response body leak into every later empty result.
|
|
20
|
+
const empty = () => ({ code: 200, body: { items: [], totalPages: 0 } });
|
|
21
|
+
/**
|
|
22
|
+
* Search Unsplash for picker candidates.
|
|
23
|
+
*
|
|
24
|
+
* Every failure that isn't "no key at all" answers 200 with an empty result
|
|
25
|
+
* set. That is deliberately preserved: both clients — the editor's
|
|
26
|
+
* ImagePickerModal and the immersive widget's — read `{ items, totalPages }`
|
|
27
|
+
* off a successful response and treat a non-ok status as a hard error banner,
|
|
28
|
+
* so promoting an upstream 403 to a 5xx here would turn a rate limit into a
|
|
29
|
+
* broken tab. The cost is that a revoked key looks exactly like "no photos
|
|
30
|
+
* match", which is invisible in both transports — hence the `log.warn`, the one
|
|
31
|
+
* thing this action adds over the Fastify original.
|
|
32
|
+
*/
|
|
33
|
+
export async function unsplashSearchAction(query, deps = {}) {
|
|
34
|
+
const accessKey = (deps.accessKey ?? process.env.UNSPLASH_ACCESS_KEY)?.trim();
|
|
35
|
+
if (!accessKey)
|
|
36
|
+
return { code: 404, body: { error: "Unsplash not configured" } };
|
|
37
|
+
const q = typeof query.q === "string" ? query.q.trim() : "";
|
|
38
|
+
if (!q)
|
|
39
|
+
return empty();
|
|
40
|
+
// `Number("") || 8` is why an absent or unparseable value lands on the
|
|
41
|
+
// default rather than on NaN; the clamp then keeps a hand-edited URL from
|
|
42
|
+
// asking Unsplash for a page size it will reject outright.
|
|
43
|
+
const limit = Math.min(20, Math.max(1, Number(query.limit) || 8));
|
|
44
|
+
const page = Math.max(1, Math.trunc(Number(query.page) || 1));
|
|
45
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
46
|
+
try {
|
|
47
|
+
const endpoint = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(q)}&orientation=landscape&per_page=${limit}&page=${page}&content_filter=high`;
|
|
48
|
+
const res = await doFetch(endpoint, {
|
|
49
|
+
headers: { Authorization: `Client-ID ${accessKey}`, "Accept-Version": "v1" }
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
deps.log?.warn({ status: res.status, q }, "unsplash search failed; returning an empty result set");
|
|
53
|
+
return empty();
|
|
54
|
+
}
|
|
55
|
+
const payload = (await res.json());
|
|
56
|
+
const items = (payload.results ?? [])
|
|
57
|
+
.map((r) => {
|
|
58
|
+
const imageUrl = typeof r.urls?.regular === "string" ? r.urls.regular : "";
|
|
59
|
+
const thumbUrl = typeof r.urls?.small === "string" ? r.urls.small : imageUrl;
|
|
60
|
+
return {
|
|
61
|
+
id: r.id ?? "",
|
|
62
|
+
imageUrl,
|
|
63
|
+
thumbUrl,
|
|
64
|
+
alt: typeof r.alt_description === "string"
|
|
65
|
+
? r.alt_description
|
|
66
|
+
: typeof r.description === "string"
|
|
67
|
+
? r.description
|
|
68
|
+
: "",
|
|
69
|
+
author: r.user?.name ?? "Unsplash"
|
|
70
|
+
};
|
|
71
|
+
})
|
|
72
|
+
// A row with no id or no URL cannot be selected or rendered, so it would
|
|
73
|
+
// only ever show up as a broken tile.
|
|
74
|
+
.filter((item) => item.id && item.imageUrl);
|
|
75
|
+
return { code: 200, body: { items, totalPages: payload.total_pages ?? 0 } };
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
deps.log?.warn({ err, q }, "unsplash search threw; returning an empty result set");
|
|
79
|
+
return empty();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Give me three ways to word this hero" — the variations endpoints, as
|
|
3
|
+
* transport-agnostic actions.
|
|
4
|
+
*
|
|
5
|
+
* The pipeline itself already lives in core and already speaks `{ code,
|
|
6
|
+
* payload }`. What was still duplicated is everything around it: the 400 body
|
|
7
|
+
* for a malformed request, and — worse — the SSE *frame vocabulary* of the
|
|
8
|
+
* streaming variant, which existed only inside the Fastify handler. Library
|
|
9
|
+
* mode (`createOrchestrator()` in the site SDK) answered "not handled" for both
|
|
10
|
+
* routes, so a second transport had to invent `variations_ready` /
|
|
11
|
+
* `image_resolved` / `done` / `error` from scratch and stay in step with a
|
|
12
|
+
* client that parses the frames by hand. Frame names drift silently: nothing
|
|
13
|
+
* type-checks a string written on one side of a socket against the string read
|
|
14
|
+
* on the other.
|
|
15
|
+
*
|
|
16
|
+
* So the frames are built here, once, and `formatVariationFrame` writes the
|
|
17
|
+
* wire bytes for callers that don't already have an SSE writer. Neither Fastify
|
|
18
|
+
* nor `Response` appears in this file.
|
|
19
|
+
*/
|
|
20
|
+
import { type VariationRequestBody, type VariationPipelineContext, type VariationResult, type VariationImageUpdate } from "../chat/variation-pipeline.js";
|
|
21
|
+
import type { ActionResult } from "./history-actions.js";
|
|
22
|
+
export type { ActionResult };
|
|
23
|
+
export type { VariationRequestBody, VariationPipelineContext };
|
|
24
|
+
export type ParsedVariationRequest = {
|
|
25
|
+
ok: true;
|
|
26
|
+
body: VariationRequestBody;
|
|
27
|
+
} | {
|
|
28
|
+
ok: false;
|
|
29
|
+
result: ActionResult;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Validate a raw request body, kept deliberately separate from the actions.
|
|
33
|
+
*
|
|
34
|
+
* Both transports need to do work *between* parsing and running: library mode
|
|
35
|
+
* awaits its bootstrap cache so the adapter's pages are in session state before
|
|
36
|
+
* the pipeline reads them, and it can only do that once it knows the scoped
|
|
37
|
+
* session key. Folding the parse into the actions would either hide that step
|
|
38
|
+
* or force this file to know about an adapter it has no business knowing about.
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseVariationRequest(raw: unknown): ParsedVariationRequest;
|
|
41
|
+
/**
|
|
42
|
+
* The session key the pipeline should read and write under.
|
|
43
|
+
*
|
|
44
|
+
* Every route that touches session state scopes the raw `session` with the
|
|
45
|
+
* request's `siteId` before using it, and the variations routes are no
|
|
46
|
+
* exception: unscoped, a multi-site request reads a draft that belongs to
|
|
47
|
+
* nobody, so `getPage` misses and every variation request 404s. The
|
|
48
|
+
* normalization matters just as much — a request with no `session` at all
|
|
49
|
+
* becomes the default key here, where leaving it `undefined` would instead trip
|
|
50
|
+
* the pipeline's "session, slug, and message are required" guard and skip
|
|
51
|
+
* `rememberVariations`, so a follow-up "regenerate" would read an empty cache.
|
|
52
|
+
*
|
|
53
|
+
* `siteIdOverride` exists for library mode, which pins the site from
|
|
54
|
+
* `createOrchestrator()` config and deliberately ignores whatever `siteId` the
|
|
55
|
+
* body carries; scoping from the body there would address a different session
|
|
56
|
+
* than the one its bootstrap cache just filled.
|
|
57
|
+
*/
|
|
58
|
+
export declare function scopeVariationSession(body: VariationRequestBody, siteIdOverride?: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Generate variations and return them in one response.
|
|
61
|
+
*
|
|
62
|
+
* `session` is a separate required argument rather than something read off
|
|
63
|
+
* `body` because the two transports derive it differently (see
|
|
64
|
+
* `scopeVariationSession`) and a body whose `session` was never scoped
|
|
65
|
+
* type-checks perfectly — the mistake would only ever show up as an empty
|
|
66
|
+
* draft at runtime. Making it explicit is what stops a future call site from
|
|
67
|
+
* silently reintroducing that.
|
|
68
|
+
*/
|
|
69
|
+
export declare function variationsAction(ctx: VariationPipelineContext, body: VariationRequestBody, session: string): Promise<ActionResult>;
|
|
70
|
+
/**
|
|
71
|
+
* The SSE frames the streaming variant emits, in the order they can occur:
|
|
72
|
+
* one `variations_ready` (text-only variants, so the modal can open in ~1s),
|
|
73
|
+
* then one `image_resolved` per variant as its image finishes, then exactly one
|
|
74
|
+
* terminal `done` or `error`.
|
|
75
|
+
*
|
|
76
|
+
* The `event` name travels *inside* the JSON payload rather than as an SSE
|
|
77
|
+
* `event:` line, because the client reads this with fetch + `getReader()`
|
|
78
|
+
* rather than `EventSource` and would never see the line.
|
|
79
|
+
*/
|
|
80
|
+
export type VariationStreamFrame = ({
|
|
81
|
+
event: "variations_ready";
|
|
82
|
+
imagesPending: boolean;
|
|
83
|
+
} & VariationResult) | ({
|
|
84
|
+
event: "image_resolved";
|
|
85
|
+
} & VariationImageUpdate) | ({
|
|
86
|
+
event: "done";
|
|
87
|
+
} & VariationResult) | {
|
|
88
|
+
event: "error";
|
|
89
|
+
error: string;
|
|
90
|
+
};
|
|
91
|
+
/** The wire encoding of one frame. Identical to what `sseWrite` produces. */
|
|
92
|
+
export declare function formatVariationFrame(frame: VariationStreamFrame): string;
|
|
93
|
+
/**
|
|
94
|
+
* Generate variations, emitting frames as they become available.
|
|
95
|
+
*
|
|
96
|
+
* Resolves once the terminal frame has been emitted; ending the stream stays
|
|
97
|
+
* with the transport, which owns the socket and has to close it on client
|
|
98
|
+
* disconnect too. A thrown pipeline is reported as an `error` frame rather than
|
|
99
|
+
* rethrown: the response status is long since committed by the time images
|
|
100
|
+
* resolve, so the only way to tell the client anything is in-band.
|
|
101
|
+
*/
|
|
102
|
+
export declare function variationsStreamAction(ctx: VariationPipelineContext, body: VariationRequestBody, session: string, emit: (frame: VariationStreamFrame) => void): Promise<void>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Give me three ways to word this hero" — the variations endpoints, as
|
|
3
|
+
* transport-agnostic actions.
|
|
4
|
+
*
|
|
5
|
+
* The pipeline itself already lives in core and already speaks `{ code,
|
|
6
|
+
* payload }`. What was still duplicated is everything around it: the 400 body
|
|
7
|
+
* for a malformed request, and — worse — the SSE *frame vocabulary* of the
|
|
8
|
+
* streaming variant, which existed only inside the Fastify handler. Library
|
|
9
|
+
* mode (`createOrchestrator()` in the site SDK) answered "not handled" for both
|
|
10
|
+
* routes, so a second transport had to invent `variations_ready` /
|
|
11
|
+
* `image_resolved` / `done` / `error` from scratch and stay in step with a
|
|
12
|
+
* client that parses the frames by hand. Frame names drift silently: nothing
|
|
13
|
+
* type-checks a string written on one side of a socket against the string read
|
|
14
|
+
* on the other.
|
|
15
|
+
*
|
|
16
|
+
* So the frames are built here, once, and `formatVariationFrame` writes the
|
|
17
|
+
* wire bytes for callers that don't already have an SSE writer. Neither Fastify
|
|
18
|
+
* nor `Response` appears in this file.
|
|
19
|
+
*/
|
|
20
|
+
import { scopedSessionKey } from "../state/session-state.js";
|
|
21
|
+
import { variationRequestBodySchema, runVariationPipeline, runVariationPipelineStreaming } from "../chat/variation-pipeline.js";
|
|
22
|
+
/**
|
|
23
|
+
* Validate a raw request body, kept deliberately separate from the actions.
|
|
24
|
+
*
|
|
25
|
+
* Both transports need to do work *between* parsing and running: library mode
|
|
26
|
+
* awaits its bootstrap cache so the adapter's pages are in session state before
|
|
27
|
+
* the pipeline reads them, and it can only do that once it knows the scoped
|
|
28
|
+
* session key. Folding the parse into the actions would either hide that step
|
|
29
|
+
* or force this file to know about an adapter it has no business knowing about.
|
|
30
|
+
*/
|
|
31
|
+
export function parseVariationRequest(raw) {
|
|
32
|
+
const parsed = variationRequestBodySchema.safeParse(raw);
|
|
33
|
+
if (!parsed.success) {
|
|
34
|
+
return { ok: false, result: { code: 400, body: { error: "invalid request body", details: parsed.error.issues } } };
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, body: parsed.data };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The session key the pipeline should read and write under.
|
|
40
|
+
*
|
|
41
|
+
* Every route that touches session state scopes the raw `session` with the
|
|
42
|
+
* request's `siteId` before using it, and the variations routes are no
|
|
43
|
+
* exception: unscoped, a multi-site request reads a draft that belongs to
|
|
44
|
+
* nobody, so `getPage` misses and every variation request 404s. The
|
|
45
|
+
* normalization matters just as much — a request with no `session` at all
|
|
46
|
+
* becomes the default key here, where leaving it `undefined` would instead trip
|
|
47
|
+
* the pipeline's "session, slug, and message are required" guard and skip
|
|
48
|
+
* `rememberVariations`, so a follow-up "regenerate" would read an empty cache.
|
|
49
|
+
*
|
|
50
|
+
* `siteIdOverride` exists for library mode, which pins the site from
|
|
51
|
+
* `createOrchestrator()` config and deliberately ignores whatever `siteId` the
|
|
52
|
+
* body carries; scoping from the body there would address a different session
|
|
53
|
+
* than the one its bootstrap cache just filled.
|
|
54
|
+
*/
|
|
55
|
+
export function scopeVariationSession(body, siteIdOverride) {
|
|
56
|
+
return scopedSessionKey(body.session, siteIdOverride ?? body.siteId);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Generate variations and return them in one response.
|
|
60
|
+
*
|
|
61
|
+
* `session` is a separate required argument rather than something read off
|
|
62
|
+
* `body` because the two transports derive it differently (see
|
|
63
|
+
* `scopeVariationSession`) and a body whose `session` was never scoped
|
|
64
|
+
* type-checks perfectly — the mistake would only ever show up as an empty
|
|
65
|
+
* draft at runtime. Making it explicit is what stops a future call site from
|
|
66
|
+
* silently reintroducing that.
|
|
67
|
+
*/
|
|
68
|
+
export async function variationsAction(ctx, body, session) {
|
|
69
|
+
const { code, payload } = await runVariationPipeline(ctx, { ...body, session });
|
|
70
|
+
return { code, body: payload };
|
|
71
|
+
}
|
|
72
|
+
/** The wire encoding of one frame. Identical to what `sseWrite` produces. */
|
|
73
|
+
export function formatVariationFrame(frame) {
|
|
74
|
+
return `data: ${JSON.stringify(frame)}\n\n`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Generate variations, emitting frames as they become available.
|
|
78
|
+
*
|
|
79
|
+
* Resolves once the terminal frame has been emitted; ending the stream stays
|
|
80
|
+
* with the transport, which owns the socket and has to close it on client
|
|
81
|
+
* disconnect too. A thrown pipeline is reported as an `error` frame rather than
|
|
82
|
+
* rethrown: the response status is long since committed by the time images
|
|
83
|
+
* resolve, so the only way to tell the client anything is in-band.
|
|
84
|
+
*/
|
|
85
|
+
export async function variationsStreamAction(ctx, body, session, emit) {
|
|
86
|
+
try {
|
|
87
|
+
const result = await runVariationPipelineStreaming(ctx, { ...body, session }, {
|
|
88
|
+
onInitial: (initial) => emit({ event: "variations_ready", ...initial }),
|
|
89
|
+
onImageResolved: (update) => emit({ event: "image_resolved", ...update })
|
|
90
|
+
});
|
|
91
|
+
// The `error` test is redundant with the status check — a 200 payload is
|
|
92
|
+
// always a VariationResult — but it is what narrows the union for the
|
|
93
|
+
// `done` spread below, so it stays.
|
|
94
|
+
if (result.code !== 200 || "error" in result.payload) {
|
|
95
|
+
emit({ event: "error", error: "error" in result.payload ? result.payload.error : "unknown" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
emit({ event: "done", ...result.payload });
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
ctx.log.warn({ err }, "variations stream failed");
|
|
102
|
+
emit({ event: "error", error: err instanceof Error ? err.message : "stream failed" });
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,4 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHandler } from "./handler/create-orchestrator.ts";
|
|
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";
|
package/dist/index.js
CHANGED
|
@@ -1 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
// @avocadostudio-ai/orchestrator-core
|
|
2
|
+
//
|
|
3
|
+
// Transport-agnostic "brain" of the orchestrator: planner, ops engine, session
|
|
4
|
+
// state, NLP, publish helpers, image generation, tools, telemetry, eval — plus
|
|
5
|
+
// `createOrchestrator`, the Web-standard handler that mounts all of it at a
|
|
6
|
+
// route in someone else's Next.js app.
|
|
7
|
+
//
|
|
8
|
+
// THIS FILE IS THE PACKAGE'S PUBLIC API. If a symbol is not exported here it is
|
|
9
|
+
// not part of the contract, and a consumer installing from the registry cannot
|
|
10
|
+
// reach it: `publishConfig.exports` lists `.` and `./cms` and nothing else.
|
|
11
|
+
//
|
|
12
|
+
// The development `exports` map still carries `"./*": "./src/*.ts"` wildcards,
|
|
13
|
+
// because apps/orchestrator (the Fastify host, never published) resolves 99
|
|
14
|
+
// files through them. That asymmetry is deliberate and guarded — see
|
|
15
|
+
// packages/shared/src/published-exports.test.ts, which fails the build if a
|
|
16
|
+
// wildcard reappears in publishConfig or if a published package deep-imports
|
|
17
|
+
// another published package outside its published subpaths.
|
|
18
|
+
//
|
|
19
|
+
// The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
|
|
20
|
+
export { createOrchestrator } from "./handler/create-orchestrator.js";
|
|
21
|
+
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "./cms/index.js";
|
|
@@ -6,7 +6,7 @@ export declare function resolveBlockRef(args: {
|
|
|
6
6
|
activeBlockId?: string;
|
|
7
7
|
fallbackType?: BlockType | null;
|
|
8
8
|
}): PageDoc["blocks"][number] | null;
|
|
9
|
-
export declare function ordinalToIndex(value: string):
|
|
9
|
+
export declare function ordinalToIndex(value: string): 2 | 0 | 1 | -1 | 3 | 4 | null;
|
|
10
10
|
export declare function resolveByDescriptor(args: {
|
|
11
11
|
descriptor: string;
|
|
12
12
|
currentPage: PageDoc;
|
|
@@ -34,6 +34,16 @@ type BlockContract = {
|
|
|
34
34
|
allowedProps: string[];
|
|
35
35
|
required: string[];
|
|
36
36
|
optional?: string[];
|
|
37
|
+
/**
|
|
38
|
+
* Props whose value is rendered as markdown, as `key` or `listKey[].key`.
|
|
39
|
+
*
|
|
40
|
+
* Without this the planner cannot tell `RichText.body` from `Hero.heading`:
|
|
41
|
+
* the contract carries prop *names* and nothing else, so the whole rich-text
|
|
42
|
+
* capability was invisible to chat and only reachable by hand in the property
|
|
43
|
+
* panel. Derived from field metadata rather than written by hand, so a block
|
|
44
|
+
* that gains a richtext field announces it without anyone remembering to.
|
|
45
|
+
*/
|
|
46
|
+
richTextProps?: string[];
|
|
37
47
|
notes: string;
|
|
38
48
|
};
|
|
39
49
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { allowedBlockTypes, blockSchemas, getAllBlockMeta, getBlockMeta, getPropDisplayName,
|
|
2
|
+
import { allowedBlockTypes, blockSchemas, getAllBlockMeta, getBlockMeta, getPropDisplayName, resolveManifestFieldMeta } from "@avocadostudio-ai/shared";
|
|
3
3
|
export function editablePropsFromBlock(block) {
|
|
4
4
|
if (!block || !block.props || typeof block.props !== "object")
|
|
5
5
|
return [];
|
|
@@ -463,10 +463,25 @@ const _blockNotes = {
|
|
|
463
463
|
CardGrid: "cards is a non-empty array of {title, description, ctaText, ctaHref, imageUrl?, imageAlt?}. columns controls the grid layout: '2', '3' (default), or '4'. cardVariant can be 'default' or 'full-bleed' — applies to all cards in the grid. Use 'full-bleed' for background images with dark overlay and white text (requires imageUrl on cards). subtitle is optional.",
|
|
464
464
|
Tabs: "tabs is a non-empty array of {label, content}. content supports richtext markdown. title is an optional section heading above the tab bar.",
|
|
465
465
|
Banner: "Full-width announcement bar. variant sets preset theme: 'info' (default blue), 'success' (green), or 'warning' (amber). For custom brand colors, set backgroundColor and/or textColor (CSS color strings, e.g. '#1a1a2e' or 'rgb(26,26,46)'). Custom colors override the variant background/text. ctaText/ctaHref are optional: set both to show a button.",
|
|
466
|
-
FAQAccordion: "items is a non-empty array of {q, a}. The
|
|
467
|
-
TwoColumn: "Image + text side-by-side layout.
|
|
466
|
+
FAQAccordion: "items is a non-empty array of {q, a}. The question (q) is plain text; the answer (a) is a richtext prop.",
|
|
467
|
+
TwoColumn: "Image + text side-by-side layout. left and right are arrays of typed children; each child's `type` decides how its `text` is read — for type 'paragraph' the text is rendered as markdown, for type 'heading' it is plain text. (That is why `text` is not listed in richTextProps: it depends on the sibling `type`.) ctaText/ctaHref are optional: set both to show a CTA button. For imageUrl: use any placeholder value (the system resolves images separately).",
|
|
468
468
|
Footer: "columns must be a non-empty array of {title, links}. links is a string with one 'Label|URL' per line (use \\n to separate). Example: 'Home|/\\nAbout|/about\\nBlog|/blog'."
|
|
469
469
|
};
|
|
470
|
+
/** Collect the markdown-bearing props of a block, flat and list-item alike. */
|
|
471
|
+
function collectRichTextProps(fields, listFields) {
|
|
472
|
+
const props = [];
|
|
473
|
+
for (const [key, field] of Object.entries(fields ?? {})) {
|
|
474
|
+
if (field.kind === "richtext")
|
|
475
|
+
props.push(key);
|
|
476
|
+
}
|
|
477
|
+
for (const [listKey, listMeta] of Object.entries(listFields ?? {})) {
|
|
478
|
+
for (const [itemKey, itemMeta] of Object.entries(listMeta.itemFields)) {
|
|
479
|
+
if (itemMeta.kind === "richtext")
|
|
480
|
+
props.push(`${listKey}[].${itemKey}`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return props;
|
|
484
|
+
}
|
|
470
485
|
/**
|
|
471
486
|
* Derive block contracts from the registry so new blocks are automatically
|
|
472
487
|
* included without maintaining a parallel hardcoded map.
|
|
@@ -479,13 +494,19 @@ const _blockNotes = {
|
|
|
479
494
|
export function blockContractsSummary(manifest) {
|
|
480
495
|
const allMeta = getAllBlockMeta();
|
|
481
496
|
const result = {};
|
|
482
|
-
|
|
497
|
+
/*
|
|
498
|
+
* A lookup of manifest block types → the whole definition, not just its
|
|
499
|
+
* properties. The definition may carry the site's own field metadata, and
|
|
500
|
+
* `kind` is what decides whether a prop is described to the planner as rich
|
|
501
|
+
* text — dropping it here is how a site's prose field ended up presented as
|
|
502
|
+
* a plain string in the block contract.
|
|
503
|
+
*/
|
|
483
504
|
const manifestByType = new Map();
|
|
484
505
|
if (manifest) {
|
|
485
506
|
for (const def of manifest.blocks) {
|
|
486
507
|
const props = def.propsSchema?.properties;
|
|
487
508
|
if (props && typeof props === "object" && !Array.isArray(props)) {
|
|
488
|
-
manifestByType.set(def.type,
|
|
509
|
+
manifestByType.set(def.type, def);
|
|
489
510
|
}
|
|
490
511
|
}
|
|
491
512
|
}
|
|
@@ -508,9 +529,9 @@ export function blockContractsSummary(manifest) {
|
|
|
508
529
|
}
|
|
509
530
|
}
|
|
510
531
|
// Merge manifest-declared props that the shared Zod schema doesn't know about
|
|
511
|
-
const
|
|
512
|
-
if (
|
|
513
|
-
for (const key of Object.keys(
|
|
532
|
+
const manifestDef = manifestByType.get(type);
|
|
533
|
+
if (manifestDef) {
|
|
534
|
+
for (const key of Object.keys(manifestDef.propsSchema.properties ?? {})) {
|
|
514
535
|
if (!allProps.includes(key)) {
|
|
515
536
|
allProps.push(key);
|
|
516
537
|
optional.push(key);
|
|
@@ -556,12 +577,15 @@ export function blockContractsSummary(manifest) {
|
|
|
556
577
|
};
|
|
557
578
|
if (optional.length > 0)
|
|
558
579
|
entry.optional = optional;
|
|
580
|
+
const richTextProps = collectRichTextProps(meta.fields, meta.listFields);
|
|
581
|
+
if (richTextProps.length > 0)
|
|
582
|
+
entry.richTextProps = richTextProps;
|
|
559
583
|
result[type] = entry;
|
|
560
584
|
}
|
|
561
585
|
// Add manifest-only blocks (not in shared registry) — derive contracts from JSON schema
|
|
562
|
-
for (const [type,
|
|
563
|
-
const derived =
|
|
564
|
-
const allProps = Object.keys(
|
|
586
|
+
for (const [type, manifestDef] of manifestByType) {
|
|
587
|
+
const derived = resolveManifestFieldMeta(manifestDef);
|
|
588
|
+
const allProps = Object.keys(manifestDef.propsSchema.properties ?? {});
|
|
565
589
|
// Derive list-field notes
|
|
566
590
|
const listParts = [];
|
|
567
591
|
for (const [listKey, listMeta] of Object.entries(derived.listFields)) {
|
|
@@ -569,9 +593,11 @@ export function blockContractsSummary(manifest) {
|
|
|
569
593
|
listParts.push(`${listKey} must be a non-empty array of {${itemKeys}}`);
|
|
570
594
|
}
|
|
571
595
|
const autoNotes = listParts.length > 0 ? listParts.join(". ") + "." : `${type} block. Never invent prop names.`;
|
|
596
|
+
const manifestRichText = collectRichTextProps(derived.fields, derived.listFields);
|
|
572
597
|
result[type] = {
|
|
573
598
|
allowedProps: allProps,
|
|
574
599
|
required: allProps, // conservative: treat all as required
|
|
600
|
+
...(manifestRichText.length > 0 ? { richTextProps: manifestRichText } : {}),
|
|
575
601
|
notes: autoNotes
|
|
576
602
|
};
|
|
577
603
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { allowedBlockTypes, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
|
|
1
|
+
import { allowedBlockTypes, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
|
|
2
2
|
import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Op reordering: create_page must precede ops targeting the same slug
|
|
@@ -459,8 +459,24 @@ export function nextBlockId(type, page) {
|
|
|
459
459
|
// ---------------------------------------------------------------------------
|
|
460
460
|
// Default block props
|
|
461
461
|
// ---------------------------------------------------------------------------
|
|
462
|
+
/*
|
|
463
|
+
* Defaults to seed a newly planned block with, before the plan's own props are
|
|
464
|
+
* merged over the top.
|
|
465
|
+
*
|
|
466
|
+
* A block type the registry knows but that declares no defaults is a site's own
|
|
467
|
+
* block, and it gets nothing. The generic CTA-shaped fallback underneath
|
|
468
|
+
* `defaultPropsForType` is for a type nobody has registered — where anything
|
|
469
|
+
* renderable beats an empty block — and applying it to a registered custom
|
|
470
|
+
* block put a `title`, a `description` and a CTA into props whose schema has no
|
|
471
|
+
* such fields. On a site that registers its blocks with a catchall (the way
|
|
472
|
+
* they must, so chat edits don't strip props Avocado doesn't declare) those
|
|
473
|
+
* four keys are not rejected: they are stored, and travel back to the CMS.
|
|
474
|
+
*/
|
|
462
475
|
export function defaultPropsForType(type) {
|
|
463
|
-
|
|
476
|
+
const declared = declaredDefaultPropsForType(type);
|
|
477
|
+
if (declared)
|
|
478
|
+
return declared;
|
|
479
|
+
return allowedBlockTypes.includes(type) ? {} : sharedDefaultPropsForType(type);
|
|
464
480
|
}
|
|
465
481
|
// ---------------------------------------------------------------------------
|
|
466
482
|
// Patch helpers
|