@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.1
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
|
+
* Where the pages in a draft came from.
|
|
3
|
+
*
|
|
4
|
+
* `GET /draft/slugs` answered 200 with a page list whatever happened, and the
|
|
5
|
+
* list alone cannot be read back to its origin. Three very different situations
|
|
6
|
+
* were indistinguishable:
|
|
7
|
+
*
|
|
8
|
+
* - the caller named a real site and got its content;
|
|
9
|
+
* - the caller omitted `siteId` entirely, so it normalised to the bundled demo
|
|
10
|
+
* site and eight plausible pages came back that belong to no site anyone
|
|
11
|
+
* asked about;
|
|
12
|
+
* - the caller named a site that does not exist, and got an empty list that
|
|
13
|
+
* reads exactly like a real site with no pages yet.
|
|
14
|
+
*
|
|
15
|
+
* An agent is the likeliest victim: it mistypes a `siteId`, sees a plausible
|
|
16
|
+
* answer, and every call after that succeeds against fiction. A human in the
|
|
17
|
+
* editor has the URL bar and a site switcher to notice with; an agent has this
|
|
18
|
+
* response and nothing else.
|
|
19
|
+
*
|
|
20
|
+
* So the response says which one it is. This is deliberately *not* a refusal —
|
|
21
|
+
* a site that has genuinely just been created is empty too, and 404-ing it
|
|
22
|
+
* would break the flow that fills it. What was missing was never the error
|
|
23
|
+
* status; it was any statement at all.
|
|
24
|
+
*/
|
|
25
|
+
export type DraftSource =
|
|
26
|
+
/** The bundled demo pages. Nobody's real content. */
|
|
27
|
+
"demo"
|
|
28
|
+
/** Read through a CMS adapter — the site's own content. */
|
|
29
|
+
| "adapter"
|
|
30
|
+
/** Fetched from the orchestrator's configured site origin, which serves one site. */
|
|
31
|
+
| "bootstrap"
|
|
32
|
+
/** This session's draft: seeded from the site earlier, or edited into being. */
|
|
33
|
+
| "draft"
|
|
34
|
+
/** Empty, and no site by this id has ever been seen in this process. */
|
|
35
|
+
| "unknown-site"
|
|
36
|
+
/** Empty, but the site is known — it genuinely has no pages yet. */
|
|
37
|
+
| "empty";
|
|
38
|
+
export type DraftProvenance = {
|
|
39
|
+
siteId: string;
|
|
40
|
+
source: DraftSource;
|
|
41
|
+
/** Plain-language statement of what the caller is looking at. */
|
|
42
|
+
note?: string;
|
|
43
|
+
/** Only on `unknown-site`: what it could have meant. */
|
|
44
|
+
knownSiteIds?: string[];
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Every site id this process has real state for.
|
|
48
|
+
*
|
|
49
|
+
* "Has an entry in `draftPages`" is not the test, and using it made this
|
|
50
|
+
* function useless: `getSessionDraft` creates the map on read, so *asking*
|
|
51
|
+
* about a mistyped site made that site known and the next answer said it was
|
|
52
|
+
* fine. The signals below are all writes.
|
|
53
|
+
*
|
|
54
|
+
* `versions` is what keeps a site known after its last page is deleted — an
|
|
55
|
+
* emptied site is not a missing one, and telling a caller to check the spelling
|
|
56
|
+
* of a site it just emptied would be its own small lie.
|
|
57
|
+
*/
|
|
58
|
+
export declare function knownSiteIds(): string[];
|
|
59
|
+
export declare function describeDraft(args: {
|
|
60
|
+
/** Exactly what the caller sent, before normalisation — `undefined` matters. */
|
|
61
|
+
requestedSiteId: unknown;
|
|
62
|
+
scopedSession: string;
|
|
63
|
+
pageCount: number;
|
|
64
|
+
/** True when a CMS adapter backs this runtime (library mode with an adapter). */
|
|
65
|
+
hasAdapter?: boolean;
|
|
66
|
+
/** Set when auto-bootstrap filled this draft, to the origin it fetched from. */
|
|
67
|
+
bootstrappedFrom?: string;
|
|
68
|
+
}): DraftProvenance;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the pages in a draft came from.
|
|
3
|
+
*
|
|
4
|
+
* `GET /draft/slugs` answered 200 with a page list whatever happened, and the
|
|
5
|
+
* list alone cannot be read back to its origin. Three very different situations
|
|
6
|
+
* were indistinguishable:
|
|
7
|
+
*
|
|
8
|
+
* - the caller named a real site and got its content;
|
|
9
|
+
* - the caller omitted `siteId` entirely, so it normalised to the bundled demo
|
|
10
|
+
* site and eight plausible pages came back that belong to no site anyone
|
|
11
|
+
* asked about;
|
|
12
|
+
* - the caller named a site that does not exist, and got an empty list that
|
|
13
|
+
* reads exactly like a real site with no pages yet.
|
|
14
|
+
*
|
|
15
|
+
* An agent is the likeliest victim: it mistypes a `siteId`, sees a plausible
|
|
16
|
+
* answer, and every call after that succeeds against fiction. A human in the
|
|
17
|
+
* editor has the URL bar and a site switcher to notice with; an agent has this
|
|
18
|
+
* response and nothing else.
|
|
19
|
+
*
|
|
20
|
+
* So the response says which one it is. This is deliberately *not* a refusal —
|
|
21
|
+
* a site that has genuinely just been created is empty too, and 404-ing it
|
|
22
|
+
* would break the flow that fills it. What was missing was never the error
|
|
23
|
+
* status; it was any statement at all.
|
|
24
|
+
*/
|
|
25
|
+
import { draftPages, siteConfigs, versions, normalizeSiteId, isLegacySiteId } from "../state/session-state.js";
|
|
26
|
+
/**
|
|
27
|
+
* Every site id this process has real state for.
|
|
28
|
+
*
|
|
29
|
+
* "Has an entry in `draftPages`" is not the test, and using it made this
|
|
30
|
+
* function useless: `getSessionDraft` creates the map on read, so *asking*
|
|
31
|
+
* about a mistyped site made that site known and the next answer said it was
|
|
32
|
+
* fine. The signals below are all writes.
|
|
33
|
+
*
|
|
34
|
+
* `versions` is what keeps a site known after its last page is deleted — an
|
|
35
|
+
* emptied site is not a missing one, and telling a caller to check the spelling
|
|
36
|
+
* of a site it just emptied would be its own small lie.
|
|
37
|
+
*/
|
|
38
|
+
export function knownSiteIds() {
|
|
39
|
+
const ids = new Set();
|
|
40
|
+
const add = (key) => {
|
|
41
|
+
const sep = key.indexOf("::");
|
|
42
|
+
if (sep > 0)
|
|
43
|
+
ids.add(key.slice(0, sep));
|
|
44
|
+
};
|
|
45
|
+
for (const key of siteConfigs.keys())
|
|
46
|
+
add(key);
|
|
47
|
+
for (const [key, pages] of draftPages)
|
|
48
|
+
if (pages.size > 0)
|
|
49
|
+
add(key);
|
|
50
|
+
for (const [key, version] of versions)
|
|
51
|
+
if (version > 0)
|
|
52
|
+
add(key);
|
|
53
|
+
return [...ids].sort();
|
|
54
|
+
}
|
|
55
|
+
export function describeDraft(args) {
|
|
56
|
+
const siteId = normalizeSiteId(args.requestedSiteId);
|
|
57
|
+
const namedASite = typeof args.requestedSiteId === "string" && args.requestedSiteId.trim().length > 0;
|
|
58
|
+
/*
|
|
59
|
+
* A key with no `::` is the legacy/demo route, and the only way to reach it
|
|
60
|
+
* is a legacy site id — which is also what an *absent* `siteId` normalises to.
|
|
61
|
+
* That collapse is the whole trap: forgetting the parameter looks identical to
|
|
62
|
+
* asking for the demo site on purpose.
|
|
63
|
+
*/
|
|
64
|
+
if (!args.scopedSession.includes("::") && isLegacySiteId(siteId)) {
|
|
65
|
+
return {
|
|
66
|
+
siteId,
|
|
67
|
+
source: "demo",
|
|
68
|
+
note: namedASite
|
|
69
|
+
? `These are Avocado's bundled demo pages, served for the demo site "${siteId}".`
|
|
70
|
+
: "No siteId was supplied, so this is Avocado's bundled demo content — not any real site's " +
|
|
71
|
+
"pages. Pass siteId to reach the site you meant."
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (args.bootstrappedFrom) {
|
|
75
|
+
return {
|
|
76
|
+
siteId,
|
|
77
|
+
source: "bootstrap",
|
|
78
|
+
note: `These pages were fetched from ${args.bootstrappedFrom}, which serves one site and answers ` +
|
|
79
|
+
`with its own content whatever siteId is asked for. They are stored under "${siteId}", but ` +
|
|
80
|
+
"that does not make them that site's pages — confirm the siteId is the one you meant."
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (args.pageCount > 0) {
|
|
84
|
+
return {
|
|
85
|
+
siteId,
|
|
86
|
+
source: args.hasAdapter ? "adapter" : "draft"
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const known = knownSiteIds();
|
|
90
|
+
if (!known.includes(siteId) && !args.hasAdapter) {
|
|
91
|
+
return {
|
|
92
|
+
siteId,
|
|
93
|
+
source: "unknown-site",
|
|
94
|
+
note: `No site "${siteId}" has been seen in this process, so this empty list is not a report ` +
|
|
95
|
+
"that the site has no pages — it is that nothing here knows the site. Check the siteId, " +
|
|
96
|
+
"or register it with POST /sites/register.",
|
|
97
|
+
knownSiteIds: known
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { siteId, source: "empty", note: `Site "${siteId}" is known and currently has no pages.` };
|
|
101
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo, redo, restore and the history log, as transport-agnostic actions.
|
|
3
|
+
*
|
|
4
|
+
* These lived only in `apps/orchestrator/src/routes/history.ts`, wired
|
|
5
|
+
* directly into Fastify. Library mode — `createOrchestrator()` in the site SDK
|
|
6
|
+
* — serves the same editor from a plain `Request`/`Response` handler, and it
|
|
7
|
+
* reimplements by hand whichever routes somebody remembered to add. It never
|
|
8
|
+
* had these, so `POST /history/undo` answered "not handled by
|
|
9
|
+
* createOrchestrator()" in a UI that shows an Undo button unconditionally,
|
|
10
|
+
* while `/ops` had been faithfully calling `pushUndo` all along: the stack
|
|
11
|
+
* filled up and nothing could ever pop it.
|
|
12
|
+
*
|
|
13
|
+
* A second hand-kept copy would drift the same way the first one did, so the
|
|
14
|
+
* logic moves 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 type { Logger } from "../logger.js";
|
|
19
|
+
/** What a caller sends. `siteId` scopes the session; both transports pass it through. */
|
|
20
|
+
export type HistoryScope = {
|
|
21
|
+
session?: string;
|
|
22
|
+
siteId?: string;
|
|
23
|
+
slug?: string;
|
|
24
|
+
};
|
|
25
|
+
export type ActionResult = {
|
|
26
|
+
code: number;
|
|
27
|
+
body: unknown;
|
|
28
|
+
};
|
|
29
|
+
/** Whether undo and redo have anything to offer for this slug. */
|
|
30
|
+
export declare function historyStatus(query: HistoryScope): ActionResult;
|
|
31
|
+
/** The version log, without the snapshots — those are large and the list view never reads them. */
|
|
32
|
+
export declare function historyLog(query: HistoryScope & {
|
|
33
|
+
limit?: string | number;
|
|
34
|
+
}): ActionResult;
|
|
35
|
+
/**
|
|
36
|
+
* Step one entry back on a slug's undo stack.
|
|
37
|
+
*
|
|
38
|
+
* A `null` entry means "the page did not exist at this point", so undoing onto
|
|
39
|
+
* it removes the page rather than restoring one — which is how undoing a
|
|
40
|
+
* `create_page` works. The same `null` goes onto the redo stack when the page
|
|
41
|
+
* exists now and did not before.
|
|
42
|
+
*/
|
|
43
|
+
export declare function historyUndoAction(body: HistoryScope, log: Logger): ActionResult;
|
|
44
|
+
/** The mirror of undo, popping the redo stack and pushing current onto undo. */
|
|
45
|
+
export declare function historyRedoAction(body: HistoryScope, log: Logger): ActionResult;
|
|
46
|
+
/**
|
|
47
|
+
* Jump to any entry in the version log by applying its stored snapshot.
|
|
48
|
+
*
|
|
49
|
+
* Independent of the undo/redo stacks, so every entry stays reachable even
|
|
50
|
+
* after jumping elsewhere. The restore is itself an edit: the current state
|
|
51
|
+
* goes onto the undo stack (so undo reverses the restore) and the log gains a
|
|
52
|
+
* `restore` entry with its own snapshot.
|
|
53
|
+
*/
|
|
54
|
+
export declare function historyRestoreAction(body: {
|
|
55
|
+
session?: string;
|
|
56
|
+
siteId?: string;
|
|
57
|
+
targetVersion?: number;
|
|
58
|
+
}, log: Logger): ActionResult;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo, redo, restore and the history log, as transport-agnostic actions.
|
|
3
|
+
*
|
|
4
|
+
* These lived only in `apps/orchestrator/src/routes/history.ts`, wired
|
|
5
|
+
* directly into Fastify. Library mode — `createOrchestrator()` in the site SDK
|
|
6
|
+
* — serves the same editor from a plain `Request`/`Response` handler, and it
|
|
7
|
+
* reimplements by hand whichever routes somebody remembered to add. It never
|
|
8
|
+
* had these, so `POST /history/undo` answered "not handled by
|
|
9
|
+
* createOrchestrator()" in a UI that shows an Undo button unconditionally,
|
|
10
|
+
* while `/ops` had been faithfully calling `pushUndo` all along: the stack
|
|
11
|
+
* filled up and nothing could ever pop it.
|
|
12
|
+
*
|
|
13
|
+
* A second hand-kept copy would drift the same way the first one did, so the
|
|
14
|
+
* logic moves 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 { scopedSessionKey, historyUndo, historyRedo, getHistoryMap, getPage, setPage, removePage, pushUndo, pushCappedHistory, bumpVersion, pushVersionEntry, getVersionLog, versions, versionLog, schedulePersistState } from "../state/session-state.js";
|
|
19
|
+
const badRequest = (error) => ({ code: 400, body: { error } });
|
|
20
|
+
/** Whether undo and redo have anything to offer for this slug. */
|
|
21
|
+
export function historyStatus(query) {
|
|
22
|
+
if (!query.session || !query.slug)
|
|
23
|
+
return badRequest("session and slug are required");
|
|
24
|
+
const session = scopedSessionKey(query.session, query.siteId);
|
|
25
|
+
const undoList = getHistoryMap(historyUndo, session).get(query.slug) ?? [];
|
|
26
|
+
const redoList = getHistoryMap(historyRedo, session).get(query.slug) ?? [];
|
|
27
|
+
return { code: 200, body: { canUndo: undoList.length > 0, canRedo: redoList.length > 0 } };
|
|
28
|
+
}
|
|
29
|
+
/** The version log, without the snapshots — those are large and the list view never reads them. */
|
|
30
|
+
export function historyLog(query) {
|
|
31
|
+
if (!query.session)
|
|
32
|
+
return badRequest("session is required");
|
|
33
|
+
const session = scopedSessionKey(query.session, query.siteId);
|
|
34
|
+
const requested = typeof query.limit === "string" ? parseInt(query.limit, 10) : query.limit;
|
|
35
|
+
const limit = requested ? Math.min(requested || 50, 100) : 50;
|
|
36
|
+
const entries = getVersionLog(session, query.slug, limit).map(({ snapshot: _snapshot, ...rest }) => rest);
|
|
37
|
+
return { code: 200, body: { entries, currentVersion: versions.get(session) ?? 0 } };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Step one entry back on a slug's undo stack.
|
|
41
|
+
*
|
|
42
|
+
* A `null` entry means "the page did not exist at this point", so undoing onto
|
|
43
|
+
* it removes the page rather than restoring one — which is how undoing a
|
|
44
|
+
* `create_page` works. The same `null` goes onto the redo stack when the page
|
|
45
|
+
* exists now and did not before.
|
|
46
|
+
*/
|
|
47
|
+
export function historyUndoAction(body, log) {
|
|
48
|
+
if (!body.session || !body.slug)
|
|
49
|
+
return badRequest("session and slug are required");
|
|
50
|
+
const session = scopedSessionKey(body.session, body.siteId);
|
|
51
|
+
const slug = body.slug;
|
|
52
|
+
const undoMap = getHistoryMap(historyUndo, session);
|
|
53
|
+
const redoMap = getHistoryMap(historyRedo, session);
|
|
54
|
+
const list = undoMap.get(slug) ?? [];
|
|
55
|
+
if (list.length === 0)
|
|
56
|
+
return badRequest("nothing to undo");
|
|
57
|
+
// May be null if the page was deleted; the snapshot still restores it.
|
|
58
|
+
const current = getPage(session, slug);
|
|
59
|
+
const prev = list.pop();
|
|
60
|
+
undoMap.set(slug, list);
|
|
61
|
+
if (prev === undefined)
|
|
62
|
+
return badRequest("nothing to undo");
|
|
63
|
+
const redoList = redoMap.get(slug) ?? [];
|
|
64
|
+
pushCappedHistory(redoList, current);
|
|
65
|
+
redoMap.set(slug, redoList);
|
|
66
|
+
if (prev === null) {
|
|
67
|
+
removePage(session, slug);
|
|
68
|
+
const previewVersion = bumpVersion(session);
|
|
69
|
+
pushVersionEntry(session, { version: previewVersion, slug, summary: "Undo", opTypes: [], opCount: 0, source: "undo", snapshot: null });
|
|
70
|
+
schedulePersistState(log);
|
|
71
|
+
return { code: 200, body: { status: "applied", previewVersion, navigateToSlug: "/", canUndo: list.length > 0, canRedo: true } };
|
|
72
|
+
}
|
|
73
|
+
setPage(session, structuredClone(prev));
|
|
74
|
+
const previewVersion = bumpVersion(session);
|
|
75
|
+
pushVersionEntry(session, { version: previewVersion, slug, summary: "Undo", opTypes: [], opCount: 0, source: "undo", snapshot: structuredClone(prev) });
|
|
76
|
+
schedulePersistState(log);
|
|
77
|
+
// The page the editor is looking at is gone, so send it somewhere real.
|
|
78
|
+
const navigateToSlug = current === null ? prev.slug : undefined;
|
|
79
|
+
return {
|
|
80
|
+
code: 200,
|
|
81
|
+
body: { status: "applied", previewVersion, canUndo: list.length > 0, canRedo: true, ...(navigateToSlug ? { navigateToSlug } : {}) }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** The mirror of undo, popping the redo stack and pushing current onto undo. */
|
|
85
|
+
export function historyRedoAction(body, log) {
|
|
86
|
+
if (!body.session || !body.slug)
|
|
87
|
+
return badRequest("session and slug are required");
|
|
88
|
+
const session = scopedSessionKey(body.session, body.siteId);
|
|
89
|
+
const slug = body.slug;
|
|
90
|
+
const undoMap = getHistoryMap(historyUndo, session);
|
|
91
|
+
const redoMap = getHistoryMap(historyRedo, session);
|
|
92
|
+
const list = redoMap.get(slug) ?? [];
|
|
93
|
+
if (list.length === 0)
|
|
94
|
+
return badRequest("nothing to redo");
|
|
95
|
+
const current = getPage(session, slug);
|
|
96
|
+
const next = list.pop();
|
|
97
|
+
redoMap.set(slug, list);
|
|
98
|
+
if (next === undefined)
|
|
99
|
+
return badRequest("nothing to redo");
|
|
100
|
+
const undoList = undoMap.get(slug) ?? [];
|
|
101
|
+
pushCappedHistory(undoList, current);
|
|
102
|
+
undoMap.set(slug, undoList);
|
|
103
|
+
if (next === null) {
|
|
104
|
+
removePage(session, slug);
|
|
105
|
+
const previewVersion = bumpVersion(session);
|
|
106
|
+
pushVersionEntry(session, { version: previewVersion, slug, summary: "Redo", opTypes: [], opCount: 0, source: "redo", snapshot: null });
|
|
107
|
+
schedulePersistState(log);
|
|
108
|
+
return { code: 200, body: { status: "applied", previewVersion, navigateToSlug: "/", canUndo: true, canRedo: list.length > 0 } };
|
|
109
|
+
}
|
|
110
|
+
setPage(session, structuredClone(next));
|
|
111
|
+
const previewVersion = bumpVersion(session);
|
|
112
|
+
pushVersionEntry(session, { version: previewVersion, slug, summary: "Redo", opTypes: [], opCount: 0, source: "redo", snapshot: structuredClone(next) });
|
|
113
|
+
schedulePersistState(log);
|
|
114
|
+
const navigateToSlug = current === null ? next.slug : undefined;
|
|
115
|
+
return {
|
|
116
|
+
code: 200,
|
|
117
|
+
body: { status: "applied", previewVersion, canUndo: true, canRedo: list.length > 0, ...(navigateToSlug ? { navigateToSlug } : {}) }
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Jump to any entry in the version log by applying its stored snapshot.
|
|
122
|
+
*
|
|
123
|
+
* Independent of the undo/redo stacks, so every entry stays reachable even
|
|
124
|
+
* after jumping elsewhere. The restore is itself an edit: the current state
|
|
125
|
+
* goes onto the undo stack (so undo reverses the restore) and the log gains a
|
|
126
|
+
* `restore` entry with its own snapshot.
|
|
127
|
+
*/
|
|
128
|
+
export function historyRestoreAction(body, log) {
|
|
129
|
+
if (!body.session || typeof body.targetVersion !== "number") {
|
|
130
|
+
return badRequest("session and targetVersion are required");
|
|
131
|
+
}
|
|
132
|
+
const session = scopedSessionKey(body.session, body.siteId);
|
|
133
|
+
const entries = versionLog.get(session) ?? [];
|
|
134
|
+
const target = entries.find((entry) => entry.version === body.targetVersion);
|
|
135
|
+
if (!target)
|
|
136
|
+
return { code: 404, body: { error: "target version not found" } };
|
|
137
|
+
if (target.snapshot === undefined) {
|
|
138
|
+
return badRequest("target version has no restorable snapshot (legacy entry)");
|
|
139
|
+
}
|
|
140
|
+
// pushUndo also clears redo, which is right: a restore starts a new branch.
|
|
141
|
+
pushUndo(session, target.slug, getPage(session, target.slug));
|
|
142
|
+
if (target.snapshot === null)
|
|
143
|
+
removePage(session, target.slug);
|
|
144
|
+
else
|
|
145
|
+
setPage(session, structuredClone(target.snapshot));
|
|
146
|
+
const previewVersion = bumpVersion(session);
|
|
147
|
+
pushVersionEntry(session, {
|
|
148
|
+
version: previewVersion,
|
|
149
|
+
slug: target.slug,
|
|
150
|
+
summary: `Restored to v${body.targetVersion}`,
|
|
151
|
+
opTypes: [],
|
|
152
|
+
opCount: 0,
|
|
153
|
+
source: "restore",
|
|
154
|
+
snapshot: target.snapshot === null ? null : structuredClone(target.snapshot)
|
|
155
|
+
});
|
|
156
|
+
schedulePersistState(log);
|
|
157
|
+
const undoList = getHistoryMap(historyUndo, session).get(target.slug) ?? [];
|
|
158
|
+
const redoList = getHistoryMap(historyRedo, session).get(target.slug) ?? [];
|
|
159
|
+
return {
|
|
160
|
+
code: 200,
|
|
161
|
+
body: {
|
|
162
|
+
status: "applied",
|
|
163
|
+
previewVersion,
|
|
164
|
+
navigateToSlug: target.slug,
|
|
165
|
+
canUndo: undoList.length > 0,
|
|
166
|
+
canRedo: redoList.length > 0
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image generation — one-shot, multi-turn Gemini chat, and screenshot
|
|
3
|
+
* interpretation — as transport-agnostic actions.
|
|
4
|
+
*
|
|
5
|
+
* These three handlers lived only in `apps/orchestrator/src/routes/media.ts`,
|
|
6
|
+
* wired directly into Fastify, even though almost everything they call has been
|
|
7
|
+
* in orchestrator-core all along. Library mode — `createOrchestrator()` in the
|
|
8
|
+
* site SDK — serves the same editor from a plain `Request`/`Response` handler
|
|
9
|
+
* and reimplements by hand whichever routes somebody remembered to add. It
|
|
10
|
+
* never had these, so the editor's image picker answered "not handled by
|
|
11
|
+
* createOrchestrator()" for a Generate tab it shows unconditionally.
|
|
12
|
+
*
|
|
13
|
+
* Two things made a second hand-kept copy worse than usual here. The multi-turn
|
|
14
|
+
* chat keeps *live* Gemini sessions in a closure-scoped Map, so a duplicate
|
|
15
|
+
* implementation would also duplicate the session store and hand the same
|
|
16
|
+
* `chatId` two different conversations. And the streaming variant has an SSE
|
|
17
|
+
* frame vocabulary — `chatId`/`status`/`text`/`image`/`error`/`done` — that
|
|
18
|
+
* existed only inside the Fastify handler, while the editor parses those names
|
|
19
|
+
* by hand: nothing type-checks a string written on one side of a socket against
|
|
20
|
+
* the string read on the other. So the frames are built here, once, and
|
|
21
|
+
* `formatImageChatFrame` writes the wire bytes.
|
|
22
|
+
*
|
|
23
|
+
* Each function returns the status code and body to send, or emits frames;
|
|
24
|
+
* neither Fastify nor `Response` appears in this file.
|
|
25
|
+
*/
|
|
26
|
+
import type { Logger } from "../logger.js";
|
|
27
|
+
import type { ActionResult } from "./history-actions.js";
|
|
28
|
+
export type { ActionResult };
|
|
29
|
+
/**
|
|
30
|
+
* The one seam that decides where a generated image is written and what URL
|
|
31
|
+
* points back at it.
|
|
32
|
+
*
|
|
33
|
+
* The Fastify route wrote straight to `ctx.generatedImageDir` and built URLs
|
|
34
|
+
* from `ctx.orchestratorPublicOrigin`; the core helpers read the equivalent
|
|
35
|
+
* environment variables. Neither is right for a library-mode consumer, which
|
|
36
|
+
* has its own `imageDir` and serves `/generated-images/…` under the base path
|
|
37
|
+
* its route is mounted at. Without an injected store the extraction would
|
|
38
|
+
* "work" everywhere and return URLs that 404 in exactly one of the two
|
|
39
|
+
* transports, which is the failure that is hardest to notice from a test.
|
|
40
|
+
*/
|
|
41
|
+
export type ImageStore = {
|
|
42
|
+
save(bytes: Buffer, prefix: string, ext: string): Promise<{
|
|
43
|
+
url: string;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The historical behaviour: `ORCHESTRATOR_GENERATED_IMAGE_DIR` +
|
|
48
|
+
* `ORCHESTRATOR_PUBLIC_ORIGIN`, via the same helper the rest of core uses. This
|
|
49
|
+
* is the default so a caller that passes no store is byte-for-byte unchanged.
|
|
50
|
+
*/
|
|
51
|
+
export declare function envImageStore(): ImageStore;
|
|
52
|
+
/** A store for a transport that already knows its own directory and public base. */
|
|
53
|
+
export declare function fileImageStore(options: {
|
|
54
|
+
dir: string;
|
|
55
|
+
publicBaseUrl: string;
|
|
56
|
+
}): ImageStore;
|
|
57
|
+
/** Only the two calls these actions make, so a test can supply a plain object. */
|
|
58
|
+
export type OpenAIImagesClient = {
|
|
59
|
+
images: {
|
|
60
|
+
generate(args: {
|
|
61
|
+
model: string;
|
|
62
|
+
prompt: string;
|
|
63
|
+
size: string;
|
|
64
|
+
}): Promise<{
|
|
65
|
+
data?: Array<{
|
|
66
|
+
b64_json?: string | null;
|
|
67
|
+
url?: string | null;
|
|
68
|
+
}> | null;
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
export type OpenAIVisionClient = {
|
|
73
|
+
chat: {
|
|
74
|
+
completions: {
|
|
75
|
+
create(args: unknown): Promise<{
|
|
76
|
+
choices?: Array<{
|
|
77
|
+
message?: {
|
|
78
|
+
content?: string | null;
|
|
79
|
+
};
|
|
80
|
+
}>;
|
|
81
|
+
}>;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
export type GeminiPart = {
|
|
86
|
+
text?: string;
|
|
87
|
+
inlineData?: {
|
|
88
|
+
data?: string;
|
|
89
|
+
mimeType?: string;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
export type GeminiContentResponse = {
|
|
93
|
+
candidates?: Array<{
|
|
94
|
+
content?: {
|
|
95
|
+
parts?: GeminiPart[];
|
|
96
|
+
};
|
|
97
|
+
}>;
|
|
98
|
+
};
|
|
99
|
+
export type GeminiImageChat = {
|
|
100
|
+
sendMessage(args: {
|
|
101
|
+
message: unknown;
|
|
102
|
+
}): Promise<GeminiContentResponse>;
|
|
103
|
+
sendMessageStream(args: {
|
|
104
|
+
message: unknown;
|
|
105
|
+
}): Promise<AsyncIterable<GeminiContentResponse>>;
|
|
106
|
+
};
|
|
107
|
+
export type GeminiImageClient = {
|
|
108
|
+
models: {
|
|
109
|
+
generateContent(args: unknown): Promise<GeminiContentResponse>;
|
|
110
|
+
};
|
|
111
|
+
chats: {
|
|
112
|
+
create(args: unknown): GeminiImageChat;
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
export type ImageActionDeps = {
|
|
116
|
+
log: Logger;
|
|
117
|
+
/** Defaults to `envImageStore()`. */
|
|
118
|
+
store?: ImageStore;
|
|
119
|
+
/** Test seams; the real clients are built from the environment when absent. */
|
|
120
|
+
openaiImages?: OpenAIImagesClient;
|
|
121
|
+
openaiVision?: OpenAIVisionClient;
|
|
122
|
+
gemini?: GeminiImageClient;
|
|
123
|
+
fetchFn?: typeof fetch;
|
|
124
|
+
};
|
|
125
|
+
export type GenerateImageBody = {
|
|
126
|
+
prompt?: string;
|
|
127
|
+
aspectRatio?: string;
|
|
128
|
+
provider?: string;
|
|
129
|
+
model?: string;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* One image from one prompt, from whichever provider is configured.
|
|
133
|
+
*
|
|
134
|
+
* The provider is chosen per request (`body.provider`) and falls back to
|
|
135
|
+
* `IMAGE_GEN_PROVIDER`, then to OpenAI. Asking for Gemini without a Google key
|
|
136
|
+
* silently falls through to OpenAI rather than failing, because the editor
|
|
137
|
+
* sends a provider hint it inferred from the user's wording and a deployment
|
|
138
|
+
* that funds only one key should still generate images.
|
|
139
|
+
*/
|
|
140
|
+
export declare function generateImageAction(body: GenerateImageBody, deps: ImageActionDeps): Promise<ActionResult>;
|
|
141
|
+
/** Drop every live session and stop the sweeper. Tests only. */
|
|
142
|
+
export declare function resetImageChatSessionsForTests(): void;
|
|
143
|
+
/**
|
|
144
|
+
* Detect an aspect-ratio override in the prompt text.
|
|
145
|
+
*
|
|
146
|
+
* The picker has a ratio control, but people also just type "make it square",
|
|
147
|
+
* and a Gemini chat's `imageConfig` is fixed for the life of the session — so
|
|
148
|
+
* the wording has to be read here, before the session is created, or the
|
|
149
|
+
* request silently produces the previous shape.
|
|
150
|
+
*/
|
|
151
|
+
export declare function detectAspectRatioFromPrompt(text: string): string | null;
|
|
152
|
+
export type ImageChatBody = {
|
|
153
|
+
prompt?: string;
|
|
154
|
+
chatId?: string;
|
|
155
|
+
aspectRatio?: string;
|
|
156
|
+
stream?: boolean;
|
|
157
|
+
referenceImageUrl?: string;
|
|
158
|
+
referenceImageUrls?: string[];
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* The cheap guards, separate from the actions because the streaming transport
|
|
162
|
+
* has to answer them *before* it commits SSE headers — after that the status
|
|
163
|
+
* code is spent and the only way to say "no key" is a frame the client would
|
|
164
|
+
* have to special-case.
|
|
165
|
+
*/
|
|
166
|
+
export declare function validateImageChatRequest(body: ImageChatBody): ActionResult | null;
|
|
167
|
+
/**
|
|
168
|
+
* Find or create the Gemini chat this request belongs to.
|
|
169
|
+
*
|
|
170
|
+
* Ratio precedence is prompt > client hint > the session's current ratio > 3:2.
|
|
171
|
+
* A ratio change on an existing session cannot be applied in place — Gemini
|
|
172
|
+
* freezes `imageConfig` when the chat is created — so the session is replaced
|
|
173
|
+
* and the caller gets a new `chatId`. That loses the conversation, which is the
|
|
174
|
+
* honest trade: keeping the old chat would keep producing the old shape.
|
|
175
|
+
*/
|
|
176
|
+
export declare function resolveImageChatSession(body: ImageChatBody, ai: GeminiImageClient, log: Logger): {
|
|
177
|
+
chatId: string;
|
|
178
|
+
chat: GeminiImageChat;
|
|
179
|
+
aspectRatio: string;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* The message to send: the prompt alone, or the prompt plus inlined reference
|
|
183
|
+
* images on the first turn (Gemini accepts up to 14).
|
|
184
|
+
*
|
|
185
|
+
* References are only inlined when the client sent no `chatId`, because after
|
|
186
|
+
* the first turn they are already in the conversation and re-sending them
|
|
187
|
+
* re-pays their tokens on every edit. A reference that will not load is warned
|
|
188
|
+
* about and skipped rather than failing the request: losing one of five moodboard
|
|
189
|
+
* images should not lose the generation.
|
|
190
|
+
*/
|
|
191
|
+
export declare function buildImageChatMessage(body: ImageChatBody, deps: ImageActionDeps): Promise<unknown>;
|
|
192
|
+
/**
|
|
193
|
+
* The SSE frames the streaming variant emits, in the order they can occur: one
|
|
194
|
+
* `chatId`, one `status`, then any number of `text` and `image` frames, and
|
|
195
|
+
* exactly one `done` — with an `error` before it if the generation broke.
|
|
196
|
+
*
|
|
197
|
+
* Unlike the variations stream, the event name goes on an SSE `event:` line
|
|
198
|
+
* rather than inside the payload, because the editor reads this one with
|
|
199
|
+
* `EventSource`-style named listeners.
|
|
200
|
+
*/
|
|
201
|
+
export type ImageChatFrame = {
|
|
202
|
+
event: "chatId";
|
|
203
|
+
chatId: string;
|
|
204
|
+
aspectRatio: string;
|
|
205
|
+
} | {
|
|
206
|
+
event: "status";
|
|
207
|
+
stage: string;
|
|
208
|
+
} | {
|
|
209
|
+
event: "text";
|
|
210
|
+
text: string;
|
|
211
|
+
} | {
|
|
212
|
+
event: "image";
|
|
213
|
+
url: string;
|
|
214
|
+
alt: string;
|
|
215
|
+
} | {
|
|
216
|
+
event: "error";
|
|
217
|
+
error: string;
|
|
218
|
+
} | {
|
|
219
|
+
event: "done";
|
|
220
|
+
};
|
|
221
|
+
/** The wire encoding of one frame. */
|
|
222
|
+
export declare function formatImageChatFrame(frame: ImageChatFrame): string;
|
|
223
|
+
/**
|
|
224
|
+
* One turn of the image chat, answered in a single JSON body.
|
|
225
|
+
*
|
|
226
|
+
* `url` is null when the model replied with text only — a refusal, or a
|
|
227
|
+
* question about the prompt — which is a normal turn, not an error.
|
|
228
|
+
*/
|
|
229
|
+
export declare function imageChatAction(body: ImageChatBody, deps: ImageActionDeps): Promise<ActionResult>;
|
|
230
|
+
/**
|
|
231
|
+
* The same turn, streamed.
|
|
232
|
+
*
|
|
233
|
+
* Resolves once the terminal `done` frame has been emitted; ending the stream
|
|
234
|
+
* stays with the transport, which owns the socket and has to close it on client
|
|
235
|
+
* disconnect too. Everything that goes wrong after the first frame — including
|
|
236
|
+
* a failure to reach Gemini at all — is reported as an `error` frame rather
|
|
237
|
+
* than thrown, because the status code is committed the moment the transport
|
|
238
|
+
* writes SSE headers and in-band is the only channel left. Callers should run
|
|
239
|
+
* `validateImageChatRequest` before those headers so a missing key is still a
|
|
240
|
+
* real 503.
|
|
241
|
+
*/
|
|
242
|
+
export declare function imageChatStreamAction(body: ImageChatBody, deps: ImageActionDeps, emit: (frame: ImageChatFrame) => void): Promise<void>;
|
|
243
|
+
/**
|
|
244
|
+
* The checks on an uploaded image, shared so both transports answer alike.
|
|
245
|
+
*
|
|
246
|
+
* Each transport decodes multipart its own way — Fastify streams
|
|
247
|
+
* `request.file()`, library mode awaits `request.formData()` — and it was the
|
|
248
|
+
* *error bodies* around that decoding, not the decoding itself, that a second
|
|
249
|
+
* implementation would get subtly different. Returns null when the upload is
|
|
250
|
+
* acceptable.
|
|
251
|
+
*/
|
|
252
|
+
export declare function validateImageAnalysisInput(input: {
|
|
253
|
+
mimeType: string;
|
|
254
|
+
byteLength: number;
|
|
255
|
+
}): ActionResult | null;
|
|
256
|
+
/**
|
|
257
|
+
* Describe a pasted screenshot in one sentence, for the chat composer to attach
|
|
258
|
+
* to the user's instruction.
|
|
259
|
+
*
|
|
260
|
+
* The image is inlined as a data URL rather than uploaded: the orchestrator has
|
|
261
|
+
* no public URL for a paste that never touched disk, and these are one-shot
|
|
262
|
+
* reads that nothing refers to again.
|
|
263
|
+
*/
|
|
264
|
+
export declare function interpretImageAction(input: {
|
|
265
|
+
bytes: Buffer;
|
|
266
|
+
byteLength: number;
|
|
267
|
+
mimeType: string;
|
|
268
|
+
}, deps: ImageActionDeps): Promise<ActionResult>;
|