@aglyn/plugins-ai 1.0.0-beta.228 → 1.0.0-beta.230
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +17 -17
- package/src/lib/activity/ai-activity-actions.js +7 -1
- package/src/lib/activity/ai-activity-actions.js.map +1 -1
- package/src/lib/components/ai-automation-zones.d.ts +38 -0
- package/src/lib/components/ai-automation-zones.js +5 -1
- package/src/lib/components/ai-automation-zones.js.map +1 -1
- package/src/lib/components/ai-brief-dialog.component.d.ts +20 -2
- package/src/lib/components/ai-brief-dialog.component.js +87 -8
- package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
- package/src/lib/components/ai-campaign-create.component.d.ts +54 -0
- package/src/lib/components/ai-campaign-create.component.js +270 -0
- package/src/lib/components/ai-campaign-create.component.js.map +1 -0
- package/src/lib/components/ai-describe-automation.component.d.ts +8 -3
- package/src/lib/components/ai-describe-automation.component.js +15 -6
- package/src/lib/components/ai-describe-automation.component.js.map +1 -1
- package/src/lib/components/ai-describe-button.component.d.ts +20 -8
- package/src/lib/components/ai-describe-button.component.js +19 -36
- package/src/lib/components/ai-describe-button.component.js.map +1 -1
- package/src/lib/components/ai-describe-email.component.d.ts +31 -0
- package/src/lib/components/ai-describe-email.component.js +22 -0
- package/src/lib/components/ai-describe-email.component.js.map +1 -0
- package/src/lib/components/ai-describe-org-automation.component.d.ts +36 -0
- package/src/lib/components/ai-describe-org-automation.component.js +238 -0
- package/src/lib/components/ai-describe-org-automation.component.js.map +1 -0
- package/src/lib/components/ai-experiment-cards.component.d.ts +9 -1
- package/src/lib/components/ai-experiment-cards.component.js +104 -18
- package/src/lib/components/ai-experiment-cards.component.js.map +1 -1
- package/src/lib/components/ai-explain-automation.component.js +1 -5
- package/src/lib/components/ai-explain-automation.component.js.map +1 -1
- package/src/lib/components/ai-funnel-zones.component.d.ts +61 -0
- package/src/lib/components/ai-funnel-zones.component.js +178 -0
- package/src/lib/components/ai-funnel-zones.component.js.map +1 -0
- package/src/lib/components/ai-insight-card.component.d.ts +42 -0
- package/src/lib/components/ai-insight-card.component.js +137 -0
- package/src/lib/components/ai-insight-card.component.js.map +1 -0
- package/src/lib/components/ai-insight-dialog.component.d.ts +6 -0
- package/src/lib/components/ai-insight-dialog.component.js +9 -3
- package/src/lib/components/ai-insight-dialog.component.js.map +1 -1
- package/src/lib/components/ai-job-plan.component.d.ts +7 -2
- package/src/lib/components/ai-job-plan.component.js +35 -5
- package/src/lib/components/ai-job-plan.component.js.map +1 -1
- package/src/lib/components/ai-job-requests.d.ts +17 -1
- package/src/lib/components/ai-job-requests.js +39 -3
- package/src/lib/components/ai-job-requests.js.map +1 -1
- package/src/lib/components/ai-jobs-list-page.component.js +12 -1
- package/src/lib/components/ai-jobs-list-page.component.js.map +1 -1
- package/src/lib/components/ai-logic-zones.d.ts +64 -0
- package/src/lib/components/ai-logic-zones.js +18 -0
- package/src/lib/components/ai-logic-zones.js.map +1 -0
- package/src/lib/components/ai-logic.component.d.ts +77 -0
- package/src/lib/components/ai-logic.component.js +553 -0
- package/src/lib/components/ai-logic.component.js.map +1 -0
- package/src/lib/components/ai-marketing-insight.component.d.ts +30 -0
- package/src/lib/components/ai-marketing-insight.component.js +80 -0
- package/src/lib/components/ai-marketing-insight.component.js.map +1 -0
- package/src/lib/components/ai-media-create.component.d.ts +55 -0
- package/src/lib/components/ai-media-create.component.js +433 -0
- package/src/lib/components/ai-media-create.component.js.map +1 -0
- package/src/lib/components/ai-overlay-cards.component.d.ts +83 -0
- package/src/lib/components/ai-overlay-cards.component.js +505 -0
- package/src/lib/components/ai-overlay-cards.component.js.map +1 -0
- package/src/lib/components/ai-product-zones.d.ts +5 -0
- package/src/lib/components/ai-product-zones.js +1 -1
- package/src/lib/components/ai-product-zones.js.map +1 -1
- package/src/lib/components/ai-products-create-button.component.d.ts +42 -0
- package/src/lib/components/ai-products-create-button.component.js +67 -0
- package/src/lib/components/ai-products-create-button.component.js.map +1 -0
- package/src/lib/components/ai-products-hub-card.component.js +100 -81
- package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
- package/src/lib/components/ai-revise-automation.component.d.ts +16 -0
- package/src/lib/components/ai-revise-automation.component.js +207 -0
- package/src/lib/components/ai-revise-automation.component.js.map +1 -0
- package/src/lib/components/ai-site-build-page.component.d.ts +1 -1
- package/src/lib/components/ai-site-build-page.component.js +177 -20
- package/src/lib/components/ai-site-build-page.component.js.map +1 -1
- package/src/lib/components/ai-site-start-card.component.d.ts +10 -1
- package/src/lib/components/ai-site-start-card.component.js +7 -4
- package/src/lib/components/ai-site-start-card.component.js.map +1 -1
- package/src/lib/components/ai-upsell-dialog.component.d.ts +53 -0
- package/src/lib/components/ai-upsell-dialog.component.js +177 -0
- package/src/lib/components/ai-upsell-dialog.component.js.map +1 -0
- package/src/lib/components/assist-build-card.component.d.ts +42 -0
- package/src/lib/components/assist-build-card.component.js +183 -0
- package/src/lib/components/assist-build-card.component.js.map +1 -0
- package/src/lib/components/assist-edit-canvas.d.ts +2 -0
- package/src/lib/components/assist-edit-canvas.js +56 -1
- package/src/lib/components/assist-edit-canvas.js.map +1 -1
- package/src/lib/components/assist-jobs-drawer.component.d.ts +11 -0
- package/src/lib/components/assist-jobs-drawer.component.js +85 -4
- package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
- package/src/lib/components/assist-panel.component.js +51 -2
- package/src/lib/components/assist-panel.component.js.map +1 -1
- package/src/lib/components/use-ai-job-run.d.ts +3 -14
- package/src/lib/components/use-ai-job-run.js +0 -50
- package/src/lib/components/use-ai-job-run.js.map +1 -1
- package/src/lib/jobs/ai-build-capabilities.d.ts +58 -0
- package/src/lib/jobs/ai-build-capabilities.js +256 -0
- package/src/lib/jobs/ai-build-capabilities.js.map +1 -0
- package/src/lib/jobs/ai-build-unit-outcome.d.ts +52 -0
- package/src/lib/jobs/ai-build-unit-outcome.js +138 -0
- package/src/lib/jobs/ai-build-unit-outcome.js.map +1 -0
- package/src/lib/jobs/ai-job-build-step.d.ts +144 -0
- package/src/lib/jobs/ai-job-build-step.js +657 -0
- package/src/lib/jobs/ai-job-build-step.js.map +1 -0
- package/src/lib/jobs/ai-job-draft-ids.js +9 -1
- package/src/lib/jobs/ai-job-draft-ids.js.map +1 -1
- package/src/lib/jobs/ai-job-drafts.d.ts +14 -4
- package/src/lib/jobs/ai-job-drafts.js +25 -4
- package/src/lib/jobs/ai-job-drafts.js.map +1 -1
- package/src/lib/jobs/ai-job-layout-site-pages.d.ts +70 -0
- package/src/lib/jobs/ai-job-layout-site-pages.js +216 -0
- package/src/lib/jobs/ai-job-layout-site-pages.js.map +1 -0
- package/src/lib/jobs/ai-job-layout-step.d.ts +2 -2
- package/src/lib/jobs/ai-job-layout-step.js +19 -1
- package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
- package/src/lib/jobs/ai-job-logic-step.d.ts +107 -0
- package/src/lib/jobs/ai-job-logic-step.js +395 -0
- package/src/lib/jobs/ai-job-logic-step.js.map +1 -0
- package/src/lib/jobs/ai-job-overlay-copy.d.ts +70 -0
- package/src/lib/jobs/ai-job-overlay-copy.js +178 -0
- package/src/lib/jobs/ai-job-overlay-copy.js.map +1 -0
- package/src/lib/jobs/ai-job-page-step.js +6 -6
- package/src/lib/jobs/ai-job-page-step.js.map +1 -1
- package/src/lib/jobs/ai-job-plan-step.d.ts +26 -1
- package/src/lib/jobs/ai-job-plan-step.js +79 -11
- package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
- package/src/lib/jobs/ai-job-site-step.d.ts +15 -1
- package/src/lib/jobs/ai-job-site-step.js +207 -58
- package/src/lib/jobs/ai-job-site-step.js.map +1 -1
- package/src/lib/jobs/ai-job-text-step.d.ts +22 -1
- package/src/lib/jobs/ai-job-text-step.js +12 -0
- package/src/lib/jobs/ai-job-text-step.js.map +1 -1
- package/src/lib/jobs/ai-job-workflow-step.d.ts +54 -4
- package/src/lib/jobs/ai-job-workflow-step.js +318 -13
- package/src/lib/jobs/ai-job-workflow-step.js.map +1 -1
- package/src/lib/jobs/ai-jobs.d.ts +90 -2
- package/src/lib/jobs/ai-jobs.js +495 -61
- package/src/lib/jobs/ai-jobs.js.map +1 -1
- package/src/lib/jobs/ai-logic-records.d.ts +40 -0
- package/src/lib/jobs/ai-logic-records.js +55 -0
- package/src/lib/jobs/ai-logic-records.js.map +1 -0
- package/src/lib/jobs/ai-site-publish.d.ts +7 -0
- package/src/lib/jobs/ai-site-publish.js +9 -0
- package/src/lib/jobs/ai-site-publish.js.map +1 -1
- package/src/lib/jobs/ai-workflow-records.d.ts +4 -0
- package/src/lib/jobs/ai-workflow-records.js +40 -1
- package/src/lib/jobs/ai-workflow-records.js.map +1 -1
- package/src/lib/model/ai-automation-draft.d.ts +18 -0
- package/src/lib/model/ai-automation-draft.js +86 -0
- package/src/lib/model/ai-automation-draft.js.map +1 -1
- package/src/lib/model/ai-build-job.d.ts +201 -0
- package/src/lib/model/ai-build-job.js +471 -0
- package/src/lib/model/ai-build-job.js.map +1 -0
- package/src/lib/model/ai-build-plan.d.ts +61 -0
- package/src/lib/model/ai-build-plan.js +154 -9
- package/src/lib/model/ai-build-plan.js.map +1 -1
- package/src/lib/model/ai-build-progress.d.ts +56 -0
- package/src/lib/model/ai-build-progress.js +128 -0
- package/src/lib/model/ai-build-progress.js.map +1 -0
- package/src/lib/model/ai-insight.d.ts +20 -10
- package/src/lib/model/ai-insight.js +72 -9
- package/src/lib/model/ai-insight.js.map +1 -1
- package/src/lib/model/ai-job-activity.js +3 -1
- package/src/lib/model/ai-job-activity.js.map +1 -1
- package/src/lib/model/ai-job-failure-copy.d.ts +20 -1
- package/src/lib/model/ai-job-failure-copy.js +28 -1
- package/src/lib/model/ai-job-failure-copy.js.map +1 -1
- package/src/lib/model/ai-job-notice.d.ts +3 -0
- package/src/lib/model/ai-job-notice.js +11 -2
- package/src/lib/model/ai-job-notice.js.map +1 -1
- package/src/lib/model/ai-jobs.types.d.ts +87 -2
- package/src/lib/model/ai-jobs.types.js +3 -1
- package/src/lib/model/ai-jobs.types.js.map +1 -1
- package/src/lib/model/ai-logic-job.d.ts +108 -0
- package/src/lib/model/ai-logic-job.js +327 -0
- package/src/lib/model/ai-logic-job.js.map +1 -0
- package/src/lib/model/ai-media-credits.d.ts +52 -0
- package/src/lib/model/ai-media-credits.js +61 -0
- package/src/lib/model/ai-media-credits.js.map +1 -0
- package/src/lib/model/ai-overlay-copy.d.ts +114 -0
- package/src/lib/model/ai-overlay-copy.js +175 -0
- package/src/lib/model/ai-overlay-copy.js.map +1 -0
- package/src/lib/model/ai-site-build-progress.d.ts +28 -3
- package/src/lib/model/ai-site-build-progress.js +231 -17
- package/src/lib/model/ai-site-build-progress.js.map +1 -1
- package/src/lib/model/ai-site-start-seo.d.ts +16 -0
- package/src/lib/model/ai-site-start-seo.js +54 -4
- package/src/lib/model/ai-site-start-seo.js.map +1 -1
- package/src/lib/model/ai-site-start.d.ts +14 -0
- package/src/lib/model/ai-site-start.js +43 -0
- package/src/lib/model/ai-site-start.js.map +1 -1
- package/src/lib/model/ai-svg.d.ts +69 -0
- package/src/lib/model/ai-svg.js +265 -0
- package/src/lib/model/ai-svg.js.map +1 -0
- package/src/lib/model/ai-usage-by-user.d.ts +3 -2
- package/src/lib/model/ai-usage-by-user.js.map +1 -1
- package/src/lib/model/ai-workflow-job.d.ts +92 -4
- package/src/lib/model/ai-workflow-job.js +124 -7
- package/src/lib/model/ai-workflow-job.js.map +1 -1
- package/src/lib/model/assist-build.d.ts +58 -0
- package/src/lib/model/assist-build.js +52 -0
- package/src/lib/model/assist-build.js.map +1 -0
- package/src/lib/model/assist-edit.d.ts +48 -3
- package/src/lib/model/assist-edit.js +48 -4
- package/src/lib/model/assist-edit.js.map +1 -1
- package/src/lib/plugin-config.js +2 -0
- package/src/lib/plugin-config.js.map +1 -1
- package/src/lib/plugin.js +240 -11
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/providers/catalog.d.ts +81 -4
- package/src/lib/providers/catalog.js +80 -9
- package/src/lib/providers/catalog.js.map +1 -1
- package/src/lib/providers/image-contract.d.ts +116 -0
- package/src/lib/providers/image-contract.js +74 -0
- package/src/lib/providers/image-contract.js.map +1 -0
- package/src/lib/providers/model-choice.js +11 -0
- package/src/lib/providers/model-choice.js.map +1 -1
- package/src/lib/providers/routing.js +18 -0
- package/src/lib/providers/routing.js.map +1 -1
- package/src/lib/providers/vertex-image.d.ts +121 -0
- package/src/lib/providers/vertex-image.js +312 -0
- package/src/lib/providers/vertex-image.js.map +1 -0
- package/src/lib/runtime/ai-doctrine-validators.d.ts +19 -0
- package/src/lib/runtime/ai-doctrine-validators.js +89 -0
- package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
- package/src/lib/runtime/ai-doctrine.d.ts +6 -0
- package/src/lib/runtime/ai-doctrine.js +16 -6
- package/src/lib/runtime/ai-doctrine.js.map +1 -1
- package/src/lib/runtime/ai-eval-live.js +4 -0
- package/src/lib/runtime/ai-eval-live.js.map +1 -1
- package/src/lib/runtime/ai-eval.d.ts +15 -1
- package/src/lib/runtime/ai-eval.js +52 -1
- package/src/lib/runtime/ai-eval.js.map +1 -1
- package/src/lib/runtime/experiment-variant-copy.d.ts +34 -0
- package/src/lib/runtime/experiment-variant-copy.js +102 -0
- package/src/lib/runtime/experiment-variant-copy.js.map +1 -0
- package/src/lib/server/ai-experiment-versions.d.ts +51 -0
- package/src/lib/server/ai-experiment-versions.js +270 -0
- package/src/lib/server/ai-experiment-versions.js.map +1 -0
- package/src/lib/server/ai-jobs-resume.js +74 -4
- package/src/lib/server/ai-jobs-resume.js.map +1 -1
- package/src/lib/server/ai-media-image.d.ts +66 -0
- package/src/lib/server/ai-media-image.js +521 -0
- package/src/lib/server/ai-media-image.js.map +1 -0
- package/src/lib/server/ai-media-svg.d.ts +91 -0
- package/src/lib/server/ai-media-svg.js +186 -0
- package/src/lib/server/ai-media-svg.js.map +1 -0
- package/src/lib/server/assist-build.d.ts +54 -0
- package/src/lib/server/assist-build.js +121 -0
- package/src/lib/server/assist-build.js.map +1 -0
- package/src/lib/server/assist-chat.js +108 -12
- package/src/lib/server/assist-chat.js.map +1 -1
- package/src/lib/server/assist-edit.js +213 -3
- package/src/lib/server/assist-edit.js.map +1 -1
- package/src/lib/server/assist-view-context.js +242 -4
- package/src/lib/server/assist-view-context.js.map +1 -1
- package/src/lib/server.js +26 -0
- package/src/lib/server.js.map +1 -1
- package/src/lib/tools/ai-logic-tool.d.ts +27 -0
- package/src/lib/tools/ai-logic-tool.js +207 -0
- package/src/lib/tools/ai-logic-tool.js.map +1 -0
- package/src/lib/tools/ai-overlay-tool.d.ts +35 -0
- package/src/lib/tools/ai-overlay-tool.js +107 -0
- package/src/lib/tools/ai-overlay-tool.js.map +1 -0
- package/src/lib/tools/ai-workflow-tool.d.ts +11 -0
- package/src/lib/tools/ai-workflow-tool.js +31 -0
- package/src/lib/tools/ai-workflow-tool.js.map +1 -1
- package/src/lib/usage/assist-job-refund.d.ts +31 -3
- package/src/lib/usage/assist-job-refund.js +46 -10
- package/src/lib/usage/assist-job-refund.js.map +1 -1
- package/src/lib/usage/assist-usage.d.ts +5 -0
- package/src/lib/usage/assist-usage.js +2 -0
- package/src/lib/usage/assist-usage.js.map +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { AI_TEXT_LIMITS } from "./ai-palette.js";
|
|
18
|
+
/**
|
|
19
|
+
* One A/B variant's copy, put into a COPY of a page's node map (AGL-3603),
|
|
20
|
+
* which the caller stores as a new unpublished version for the variant to
|
|
21
|
+
* pin. Nothing here reads or writes a document, and the map it is handed is
|
|
22
|
+
* never changed.
|
|
23
|
+
*
|
|
24
|
+
* The copy lands in the region the test varies: the whole page's content for
|
|
25
|
+
* a page test, the one section's subtree for a section test. Within it the
|
|
26
|
+
* first heading takes the variant's headline and the first plain text after
|
|
27
|
+
* it takes the body — the same two places the copy under test was read from
|
|
28
|
+
* when a person pasted "the headline and copy as they stand". Rich text is
|
|
29
|
+
* never rewritten, structure is never changed, and a region with no plain
|
|
30
|
+
* heading or text to take the copy says so rather than guessing.
|
|
31
|
+
*/ /** The text elements a variant's copy may be written into, as the SEO fixes read them. */ const TEXT_COMPONENTS = new Set([
|
|
32
|
+
'muiTypography',
|
|
33
|
+
'muiInlineText',
|
|
34
|
+
'muiListItemText'
|
|
35
|
+
]);
|
|
36
|
+
const HEADING = /^h[1-6]$/;
|
|
37
|
+
const isRecord = (value)=>typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
38
|
+
/** A text element's own plain text, or `null` for one a variant may not rewrite. */ function plainText(node) {
|
|
39
|
+
if (!node || !TEXT_COMPONENTS.has(String(node['componentId']))) return null;
|
|
40
|
+
const props = isRecord(node.props) ? node.props : {};
|
|
41
|
+
if (typeof props['html'] === 'string' && props['html'].trim()) return null;
|
|
42
|
+
return typeof props['children'] === 'string' ? props['children'] : null;
|
|
43
|
+
}
|
|
44
|
+
function isHeading(node) {
|
|
45
|
+
var _props_component, _props_variant;
|
|
46
|
+
const props = isRecord(node.props) ? node.props : {};
|
|
47
|
+
return HEADING.test(String((_props_component = props['component']) != null ? _props_component : '')) || HEADING.test(String((_props_variant = props['variant']) != null ? _props_variant : ''));
|
|
48
|
+
}
|
|
49
|
+
/** The region's elements in document order, the region's root first. */ function walk(nodes, rootId) {
|
|
50
|
+
const order = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
const visit = (id)=>{
|
|
53
|
+
if (seen.has(id) || !nodes[id]) return;
|
|
54
|
+
seen.add(id);
|
|
55
|
+
order.push(id);
|
|
56
|
+
const children = Array.isArray(nodes[id].nodes) ? nodes[id].nodes : [];
|
|
57
|
+
for (const child of children)if (typeof child === 'string') visit(child);
|
|
58
|
+
};
|
|
59
|
+
visit(rootId);
|
|
60
|
+
return order;
|
|
61
|
+
}
|
|
62
|
+
const line = (value, limit)=>value.replace(/\s+/g, ' ').trim().slice(0, limit).trim();
|
|
63
|
+
/** Put one variant's copy into a copy of `nodes`, inside the region rooted at `regionRootId`. */ export function applyAiExperimentVariantCopy(nodes, copy, regionRootId) {
|
|
64
|
+
const result = JSON.parse(JSON.stringify(nodes));
|
|
65
|
+
if (!regionRootId || !result[regionRootId]) {
|
|
66
|
+
return {
|
|
67
|
+
nodes: result,
|
|
68
|
+
changed: [],
|
|
69
|
+
reason: 'The part of the page under test is no longer there.'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const order = walk(result, regionRootId);
|
|
73
|
+
const headline = line(copy.headline, AI_TEXT_LIMITS.headline);
|
|
74
|
+
const body = copy.body.replace(/\r\n?/g, '\n').trim().slice(0, AI_TEXT_LIMITS.body).trim();
|
|
75
|
+
const changed = [];
|
|
76
|
+
const headingAt = order.findIndex((id)=>plainText(result[id]) !== null && isHeading(result[id]));
|
|
77
|
+
if (headline && headingAt >= 0) {
|
|
78
|
+
var _node_props;
|
|
79
|
+
const node = result[order[headingAt]];
|
|
80
|
+
node.props = _extends({}, (_node_props = node.props) != null ? _node_props : {}, {
|
|
81
|
+
children: headline
|
|
82
|
+
});
|
|
83
|
+
changed.push('headline');
|
|
84
|
+
}
|
|
85
|
+
// The body is the first plain text after the heading, or in the region when it has none.
|
|
86
|
+
const bodyAt = order.findIndex((id, index)=>index > headingAt && plainText(result[id]) !== null && !isHeading(result[id]));
|
|
87
|
+
if (body && bodyAt >= 0) {
|
|
88
|
+
var _node_props1;
|
|
89
|
+
const node = result[order[bodyAt]];
|
|
90
|
+
node.props = _extends({}, (_node_props1 = node.props) != null ? _node_props1 : {}, {
|
|
91
|
+
children: body
|
|
92
|
+
});
|
|
93
|
+
changed.push('body');
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
nodes: result,
|
|
97
|
+
changed,
|
|
98
|
+
reason: changed.length ? null : 'This part of the page has no plain heading or text for the variant’s copy.'
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
//# sourceMappingURL=experiment-variant-copy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/experiment-variant-copy.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { AI_TEXT_LIMITS } from './ai-palette'\n\n/**\n * One A/B variant's copy, put into a COPY of a page's node map (AGL-3603),\n * which the caller stores as a new unpublished version for the variant to\n * pin. Nothing here reads or writes a document, and the map it is handed is\n * never changed.\n *\n * The copy lands in the region the test varies: the whole page's content for\n * a page test, the one section's subtree for a section test. Within it the\n * first heading takes the variant's headline and the first plain text after\n * it takes the body — the same two places the copy under test was read from\n * when a person pasted \"the headline and copy as they stand\". Rich text is\n * never rewritten, structure is never changed, and a region with no plain\n * heading or text to take the copy says so rather than guessing.\n */\n\n/** The text elements a variant's copy may be written into, as the SEO fixes read them. */\nconst TEXT_COMPONENTS: ReadonlySet<string> = new Set(['muiTypography', 'muiInlineText', 'muiListItemText'])\n\nconst HEADING = /^h[1-6]$/\n\ntype StoredNode = Record<string, unknown> & { props?: Record<string, unknown>; nodes?: unknown }\n\nconst isRecord = (value: unknown): value is Record<string, unknown> =>\n typeof value === 'object' && value !== null && !Array.isArray(value)\n\nexport interface AiExperimentVariantCopy {\n headline: string\n body: string\n}\n\nexport interface AiExperimentVariantCopyResult {\n nodes: Record<string, StoredNode>\n /** Which of the two the copy changed. */\n changed: Array<'headline' | 'body'>\n /** Why nothing changed, when nothing did; customer-safe. */\n reason: string | null\n}\n\n/** A text element's own plain text, or `null` for one a variant may not rewrite. */\nfunction plainText(node: StoredNode | undefined): string | null {\n if (!node || !TEXT_COMPONENTS.has(String(node['componentId']))) return null\n const props = isRecord(node.props) ? node.props : {}\n if (typeof props['html'] === 'string' && props['html'].trim()) return null\n return typeof props['children'] === 'string' ? props['children'] : null\n}\n\nfunction isHeading(node: StoredNode): boolean {\n const props = isRecord(node.props) ? node.props : {}\n return HEADING.test(String(props['component'] ?? '')) || HEADING.test(String(props['variant'] ?? ''))\n}\n\n/** The region's elements in document order, the region's root first. */\nfunction walk(nodes: Record<string, StoredNode>, rootId: string): string[] {\n const order: string[] = []\n const seen = new Set<string>()\n const visit = (id: string) => {\n if (seen.has(id) || !nodes[id]) return\n seen.add(id)\n order.push(id)\n const children = Array.isArray(nodes[id].nodes) ? (nodes[id].nodes as unknown[]) : []\n for (const child of children) if (typeof child === 'string') visit(child)\n }\n visit(rootId)\n return order\n}\n\nconst line = (value: string, limit: number) => value.replace(/\\s+/g, ' ').trim().slice(0, limit).trim()\n\n/** Put one variant's copy into a copy of `nodes`, inside the region rooted at `regionRootId`. */\nexport function applyAiExperimentVariantCopy(\n nodes: Record<string, unknown>,\n copy: AiExperimentVariantCopy,\n regionRootId: string | null,\n): AiExperimentVariantCopyResult {\n const result = JSON.parse(JSON.stringify(nodes)) as Record<string, StoredNode>\n if (!regionRootId || !result[regionRootId]) {\n return { nodes: result, changed: [], reason: 'The part of the page under test is no longer there.' }\n }\n const order = walk(result, regionRootId)\n const headline = line(copy.headline, AI_TEXT_LIMITS.headline)\n const body = copy.body.replace(/\\r\\n?/g, '\\n').trim().slice(0, AI_TEXT_LIMITS.body).trim()\n const changed: AiExperimentVariantCopyResult['changed'] = []\n\n const headingAt = order.findIndex((id) => plainText(result[id]) !== null && isHeading(result[id]))\n if (headline && headingAt >= 0) {\n const node = result[order[headingAt]]\n node.props = { ...(node.props ?? {}), children: headline }\n changed.push('headline')\n }\n // The body is the first plain text after the heading, or in the region when it has none.\n const bodyAt = order.findIndex(\n (id, index) => index > headingAt && plainText(result[id]) !== null && !isHeading(result[id]),\n )\n if (body && bodyAt >= 0) {\n const node = result[order[bodyAt]]\n node.props = { ...(node.props ?? {}), children: body }\n changed.push('body')\n }\n return {\n nodes: result,\n changed,\n reason: changed.length ? null : 'This part of the page has no plain heading or text for the variant’s copy.',\n }\n}\n"],"names":["AI_TEXT_LIMITS","TEXT_COMPONENTS","Set","HEADING","isRecord","value","Array","isArray","plainText","node","has","String","props","trim","isHeading","test","walk","nodes","rootId","order","seen","visit","id","add","push","children","child","line","limit","replace","slice","applyAiExperimentVariantCopy","copy","regionRootId","result","JSON","parse","stringify","changed","reason","headline","body","headingAt","findIndex","bodyAt","index","length"],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { AiJob } from '../model/ai-jobs.types';
|
|
18
|
+
import { type AiExperimentVariantsProposalView } from '../model/ai-experiment-proposal';
|
|
19
|
+
/** The id a job's variant version is stored under. */
|
|
20
|
+
export declare function aiExperimentVersionId(jobId: string, index: number): string;
|
|
21
|
+
/** What a version made from a variant is named in the versions list. */
|
|
22
|
+
export declare function aiExperimentVersionName(name: string, index: number): string;
|
|
23
|
+
export interface AiExperimentVersionsInput {
|
|
24
|
+
hostId: string;
|
|
25
|
+
screenId: string;
|
|
26
|
+
/** A section test's element; `null` for a page test. */
|
|
27
|
+
nodeId: string | null;
|
|
28
|
+
job: Pick<AiJob, '$id'>;
|
|
29
|
+
proposal: AiExperimentVariantsProposalView;
|
|
30
|
+
uid: string;
|
|
31
|
+
now?: Date;
|
|
32
|
+
}
|
|
33
|
+
export interface AiExperimentVersionsResult {
|
|
34
|
+
/** One per variant past the control, in order. */
|
|
35
|
+
versions: Array<{
|
|
36
|
+
index: number;
|
|
37
|
+
name: string;
|
|
38
|
+
versionId: string;
|
|
39
|
+
changed: string[];
|
|
40
|
+
}>;
|
|
41
|
+
skipped: Array<{
|
|
42
|
+
index: number;
|
|
43
|
+
reason: string;
|
|
44
|
+
}>;
|
|
45
|
+
/** Why no version could be made at all, customer-safe; `null` when one could. */
|
|
46
|
+
refusal: string | null;
|
|
47
|
+
}
|
|
48
|
+
/** Make the versions. Exported for the spec, which drives it against a Firestore double. */
|
|
49
|
+
export declare function createAiExperimentVersions(firestore: FirebaseFirestore.Firestore, input: AiExperimentVersionsInput): Promise<AiExperimentVersionsResult>;
|
|
50
|
+
export declare function POST(request: Request): Promise<Response>;
|
|
51
|
+
export declare const dynamic = "force-dynamic";
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ // lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts
|
|
18
|
+
// The gate carries the org verdict; the handler adds the site's own below,
|
|
19
|
+
// with the host document in hand.
|
|
20
|
+
import { checkEntitlement, encodeStoredNodes, hostRoleCanWrite } from "@aglyn/aglyn/server";
|
|
21
|
+
import { pageContentRootId } from "@aglyn/aglyn/app-utils/page-markdown";
|
|
22
|
+
import { decodeStoredNodes } from "@aglyn/aglyn/app-utils/stored-nodes";
|
|
23
|
+
import { lockdownRefusal } from "@aglyn/tenant-data-admin/server/lockdown";
|
|
24
|
+
import { getAiJob, writeAiJobAudit } from "../jobs/ai-jobs.js";
|
|
25
|
+
import { readAiExperimentVariants } from "../model/ai-experiment-proposal.js";
|
|
26
|
+
import { applyAiExperimentVariantCopy } from "../runtime/experiment-variant-copy.js";
|
|
27
|
+
import { AI_SEO_APPLY_VERSIONING_COPY } from "./ai-seo-apply.js";
|
|
28
|
+
import { aiJobsGate } from "./ai-jobs-gate.js";
|
|
29
|
+
/**
|
|
30
|
+
* A page's or a section's A/B variants as draft versions (AGL-3603):
|
|
31
|
+
* `POST /api/ai/experiments/versions { orgId, hostId, jobId, screenId, nodeId? }`.
|
|
32
|
+
*
|
|
33
|
+
* An `experiment` job proposes the variants' copy. For an email that copy IS
|
|
34
|
+
* the variant, and the editor holds it. For a page or a section the variant is
|
|
35
|
+
* a version of the page, which the editor only pins — so this door makes one
|
|
36
|
+
* per proposed variant past the first: the page's PUBLISHED version is
|
|
37
|
+
* copied, the variant's headline and body are put into the region the test
|
|
38
|
+
* varies (`runtime/experiment-variant-copy.ts`), and the copy is stored as a
|
|
39
|
+
* new unpublished version named for the variant.
|
|
40
|
+
*
|
|
41
|
+
* ## Nothing goes live
|
|
42
|
+
*
|
|
43
|
+
* The published version, the screen's `versionId` and the experiment document
|
|
44
|
+
* are never touched. The A/B testing editor pins each new version to its
|
|
45
|
+
* variant as an unsaved change; the person reviews the versions, saves the
|
|
46
|
+
* test and starts it. The first proposed variant is the copy as it stands, so
|
|
47
|
+
* it is the control and pins the published version, as the editor's default
|
|
48
|
+
* already does.
|
|
49
|
+
*
|
|
50
|
+
* ## The rungs
|
|
51
|
+
*
|
|
52
|
+
* The jobs read gate (membership, the release flag, `aiGenerative`, the org
|
|
53
|
+
* lockdown); then the site — the job's and the org's — a role that writes the
|
|
54
|
+
* site's content, the site's own lockdown verdict, and the `versioning`
|
|
55
|
+
* entitlement, because a page that already has a version gains retained
|
|
56
|
+
* history here, which is what that entitlement sells. Nothing spends a token.
|
|
57
|
+
*
|
|
58
|
+
* Idempotent: each version's id is derived from the job and the variant, so
|
|
59
|
+
* a second press finds the versions the first made.
|
|
60
|
+
*/ const ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/;
|
|
61
|
+
/** The id a job's variant version is stored under. */ export function aiExperimentVersionId(jobId, index) {
|
|
62
|
+
return `ab-${jobId}-${index}`;
|
|
63
|
+
}
|
|
64
|
+
/** What a version made from a variant is named in the versions list. */ export function aiExperimentVersionName(name, index) {
|
|
65
|
+
const label = name.replace(/\s+/g, ' ').trim() || `Variant ${index + 1}`;
|
|
66
|
+
return `A/B: ${label}`.slice(0, 80);
|
|
67
|
+
}
|
|
68
|
+
const str = (value)=>typeof value === 'string' ? value.trim() : '';
|
|
69
|
+
/** Make the versions. Exported for the spec, which drives it against a Firestore double. */ export async function createAiExperimentVersions(firestore, input) {
|
|
70
|
+
var _input_now, _input_nodeId;
|
|
71
|
+
const now = (_input_now = input.now) != null ? _input_now : new Date();
|
|
72
|
+
const screenRef = firestore.collection('hosts').doc(input.hostId).collection('screens').doc(input.screenId);
|
|
73
|
+
const screen = await screenRef.get();
|
|
74
|
+
const empty = (refusal)=>({
|
|
75
|
+
versions: [],
|
|
76
|
+
skipped: [],
|
|
77
|
+
refusal
|
|
78
|
+
});
|
|
79
|
+
if (!screen.exists || screen.get('deletedAt')) return empty('The page under test no longer exists.');
|
|
80
|
+
const publishedId = str(screen.get('versionId'));
|
|
81
|
+
const source = publishedId ? await screenRef.collection('versions').doc(publishedId).get() : null;
|
|
82
|
+
const sourceData = (source == null ? void 0 : source.exists) ? source.data() : null;
|
|
83
|
+
const nodes = sourceData ? decodeStoredNodes(sourceData['nodes']) : null;
|
|
84
|
+
if (!sourceData || !nodes) return empty('The page has no published version to start from.');
|
|
85
|
+
const rootId = typeof sourceData['rootId'] === 'string' ? sourceData['rootId'] : undefined;
|
|
86
|
+
const regionRootId = (_input_nodeId = input.nodeId) != null ? _input_nodeId : pageContentRootId(nodes, rootId);
|
|
87
|
+
const versions = [];
|
|
88
|
+
const skipped = [];
|
|
89
|
+
for (const [index, variant] of input.proposal.variants.entries()){
|
|
90
|
+
// The first variant is the copy as it stands: the control, on the published version.
|
|
91
|
+
if (index === 0) continue;
|
|
92
|
+
const versionId = aiExperimentVersionId(input.job.$id, index);
|
|
93
|
+
const name = aiExperimentVersionName(variant.name, index);
|
|
94
|
+
const versionRef = screenRef.collection('versions').doc(versionId);
|
|
95
|
+
const earlier = await versionRef.get();
|
|
96
|
+
if (earlier.exists && !earlier.get('deletedAt')) {
|
|
97
|
+
versions.push({
|
|
98
|
+
index,
|
|
99
|
+
name: str(earlier.get('displayName')) || name,
|
|
100
|
+
versionId,
|
|
101
|
+
changed: []
|
|
102
|
+
});
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const result = applyAiExperimentVariantCopy(nodes, {
|
|
106
|
+
headline: variant.headline,
|
|
107
|
+
body: variant.body
|
|
108
|
+
}, regionRootId);
|
|
109
|
+
if (!result.changed.length) {
|
|
110
|
+
var _result_reason;
|
|
111
|
+
skipped.push({
|
|
112
|
+
index,
|
|
113
|
+
reason: (_result_reason = result.reason) != null ? _result_reason : 'Nothing on the page took the variant’s copy.'
|
|
114
|
+
});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// Compressed at rest, as every write of a version's nodes is.
|
|
118
|
+
const packed = encodeStoredNodes(result.nodes);
|
|
119
|
+
await versionRef.set(_extends({}, sourceData, {
|
|
120
|
+
nodes: packed ? Buffer.from(packed) : result.nodes,
|
|
121
|
+
displayName: name,
|
|
122
|
+
createdAt: now,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
createdBy: input.uid
|
|
125
|
+
}));
|
|
126
|
+
versions.push({
|
|
127
|
+
index,
|
|
128
|
+
name,
|
|
129
|
+
versionId,
|
|
130
|
+
changed: result.changed
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
versions,
|
|
135
|
+
skipped,
|
|
136
|
+
refusal: null
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export async function POST(request) {
|
|
140
|
+
var _body_orgId, _host_memberRoles, _job_outputs;
|
|
141
|
+
var _find;
|
|
142
|
+
let body;
|
|
143
|
+
try {
|
|
144
|
+
var _ref;
|
|
145
|
+
body = (_ref = await request.json()) != null ? _ref : {};
|
|
146
|
+
} catch (unused) {
|
|
147
|
+
body = {};
|
|
148
|
+
}
|
|
149
|
+
const gate = await aiJobsGate(request, String((_body_orgId = body['orgId']) != null ? _body_orgId : ''));
|
|
150
|
+
if (gate instanceof Response) return gate;
|
|
151
|
+
const hostId = str(body['hostId']);
|
|
152
|
+
const jobId = str(body['jobId']);
|
|
153
|
+
const screenId = str(body['screenId']);
|
|
154
|
+
const nodeId = str(body['nodeId']) || null;
|
|
155
|
+
if (!ID_CHARS.test(hostId) || !ID_CHARS.test(jobId) || !ID_CHARS.test(screenId)) {
|
|
156
|
+
return Response.json({
|
|
157
|
+
error: 'Name the site, the page under test and the variants to make'
|
|
158
|
+
}, {
|
|
159
|
+
status: 400
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (nodeId !== null && !ID_CHARS.test(nodeId)) {
|
|
163
|
+
return Response.json({
|
|
164
|
+
error: 'That is not a section of the page'
|
|
165
|
+
}, {
|
|
166
|
+
status: 400
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const { firestore, orgId, uid, staff, org, decoded } = gate;
|
|
170
|
+
const hostSnapshot = await firestore.collection('hosts').doc(hostId).get();
|
|
171
|
+
const host = hostSnapshot.exists ? hostSnapshot.data() : null;
|
|
172
|
+
if (!host || host['orgId'] !== orgId) {
|
|
173
|
+
return Response.json({
|
|
174
|
+
error: 'Unknown site'
|
|
175
|
+
}, {
|
|
176
|
+
status: 404
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const role = ((_host_memberRoles = host['memberRoles']) != null ? _host_memberRoles : {})[uid];
|
|
180
|
+
if (!staff && !hostRoleCanWrite(role)) {
|
|
181
|
+
return Response.json({
|
|
182
|
+
error: 'Editing requires the editor role'
|
|
183
|
+
}, {
|
|
184
|
+
status: 403
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const locked = await lockdownRefusal({
|
|
188
|
+
request,
|
|
189
|
+
staff,
|
|
190
|
+
uid,
|
|
191
|
+
org: org,
|
|
192
|
+
host: host
|
|
193
|
+
});
|
|
194
|
+
if (locked) return locked;
|
|
195
|
+
if (!checkEntitlement(org, 'versioning')) {
|
|
196
|
+
return Response.json({
|
|
197
|
+
error: AI_SEO_APPLY_VERSIONING_COPY
|
|
198
|
+
}, {
|
|
199
|
+
status: 403
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const job = await getAiJob(firestore, orgId, jobId);
|
|
203
|
+
if (!job || job.kind !== 'experiment' || job.hostId !== hostId) {
|
|
204
|
+
return Response.json({
|
|
205
|
+
error: 'Unknown variants'
|
|
206
|
+
}, {
|
|
207
|
+
status: 404
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (job.status !== 'done') {
|
|
211
|
+
return Response.json({
|
|
212
|
+
error: 'The variants are still being written'
|
|
213
|
+
}, {
|
|
214
|
+
status: 409
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const proposal = readAiExperimentVariants((_find = ((_job_outputs = job.outputs) != null ? _job_outputs : []).find((output)=>output.resource === 'experiment')) == null ? void 0 : _find.proposal);
|
|
218
|
+
if (!proposal || proposal.target === 'email') {
|
|
219
|
+
return Response.json({
|
|
220
|
+
error: 'These variants are not a page’s or a section’s'
|
|
221
|
+
}, {
|
|
222
|
+
status: 409
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (proposal.target === 'section' && !nodeId) {
|
|
226
|
+
return Response.json({
|
|
227
|
+
error: 'Pick the section under test first'
|
|
228
|
+
}, {
|
|
229
|
+
status: 400
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
const result = await createAiExperimentVersions(firestore, {
|
|
233
|
+
hostId,
|
|
234
|
+
screenId,
|
|
235
|
+
nodeId: proposal.target === 'section' ? nodeId : null,
|
|
236
|
+
job,
|
|
237
|
+
proposal,
|
|
238
|
+
uid
|
|
239
|
+
});
|
|
240
|
+
if (result.refusal) return Response.json({
|
|
241
|
+
error: result.refusal
|
|
242
|
+
}, {
|
|
243
|
+
status: 409
|
|
244
|
+
});
|
|
245
|
+
const made = result.versions.filter((entry)=>entry.changed.length);
|
|
246
|
+
if (made.length) {
|
|
247
|
+
var _decoded_email;
|
|
248
|
+
await writeAiJobAudit(firestore, {
|
|
249
|
+
action: 'ai.job.apply',
|
|
250
|
+
actorUid: uid,
|
|
251
|
+
actorEmail: (_decoded_email = decoded.email) != null ? _decoded_email : null,
|
|
252
|
+
orgId,
|
|
253
|
+
jobId,
|
|
254
|
+
after: {
|
|
255
|
+
hostId,
|
|
256
|
+
screenId,
|
|
257
|
+
versions: made.map((entry)=>entry.versionId)
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return Response.json(result, {
|
|
262
|
+
status: 200,
|
|
263
|
+
headers: {
|
|
264
|
+
'Cache-Control': 'no-store'
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
export const dynamic = 'force-dynamic';
|
|
269
|
+
|
|
270
|
+
//# sourceMappingURL=ai-experiment-versions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/server/ai-experiment-versions.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// lockdown-423: via libs/plugins/ai/src/lib/server/ai-jobs-gate.ts\n// The gate carries the org verdict; the handler adds the site's own below,\n// with the host document in hand.\n\nimport {\n checkEntitlement,\n encodeStoredNodes,\n hostRoleCanWrite,\n} from '@aglyn/aglyn/server'\nimport { pageContentRootId } from '@aglyn/aglyn/app-utils/page-markdown'\nimport { decodeStoredNodes } from '@aglyn/aglyn/app-utils/stored-nodes'\nimport { lockdownRefusal } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { getAiJob, writeAiJobAudit } from '../jobs/ai-jobs'\nimport type { AiJob } from '../model/ai-jobs.types'\nimport {\n readAiExperimentVariants,\n type AiExperimentVariantsProposalView,\n} from '../model/ai-experiment-proposal'\nimport { applyAiExperimentVariantCopy } from '../runtime/experiment-variant-copy'\nimport { AI_SEO_APPLY_VERSIONING_COPY } from './ai-seo-apply'\nimport { aiJobsGate } from './ai-jobs-gate'\n\n/**\n * A page's or a section's A/B variants as draft versions (AGL-3603):\n * `POST /api/ai/experiments/versions { orgId, hostId, jobId, screenId, nodeId? }`.\n *\n * An `experiment` job proposes the variants' copy. For an email that copy IS\n * the variant, and the editor holds it. For a page or a section the variant is\n * a version of the page, which the editor only pins — so this door makes one\n * per proposed variant past the first: the page's PUBLISHED version is\n * copied, the variant's headline and body are put into the region the test\n * varies (`runtime/experiment-variant-copy.ts`), and the copy is stored as a\n * new unpublished version named for the variant.\n *\n * ## Nothing goes live\n *\n * The published version, the screen's `versionId` and the experiment document\n * are never touched. The A/B testing editor pins each new version to its\n * variant as an unsaved change; the person reviews the versions, saves the\n * test and starts it. The first proposed variant is the copy as it stands, so\n * it is the control and pins the published version, as the editor's default\n * already does.\n *\n * ## The rungs\n *\n * The jobs read gate (membership, the release flag, `aiGenerative`, the org\n * lockdown); then the site — the job's and the org's — a role that writes the\n * site's content, the site's own lockdown verdict, and the `versioning`\n * entitlement, because a page that already has a version gains retained\n * history here, which is what that entitlement sells. Nothing spends a token.\n *\n * Idempotent: each version's id is derived from the job and the variant, so\n * a second press finds the versions the first made.\n */\n\nconst ID_CHARS = /^[A-Za-z0-9_-]{1,100}$/\n\n/** The id a job's variant version is stored under. */\nexport function aiExperimentVersionId(jobId: string, index: number): string {\n return `ab-${jobId}-${index}`\n}\n\n/** What a version made from a variant is named in the versions list. */\nexport function aiExperimentVersionName(name: string, index: number): string {\n const label = name.replace(/\\s+/g, ' ').trim() || `Variant ${index + 1}`\n return `A/B: ${label}`.slice(0, 80)\n}\n\nexport interface AiExperimentVersionsInput {\n hostId: string\n screenId: string\n /** A section test's element; `null` for a page test. */\n nodeId: string | null\n job: Pick<AiJob, '$id'>\n proposal: AiExperimentVariantsProposalView\n uid: string\n now?: Date\n}\n\nexport interface AiExperimentVersionsResult {\n /** One per variant past the control, in order. */\n versions: Array<{ index: number; name: string; versionId: string; changed: string[] }>\n skipped: Array<{ index: number; reason: string }>\n /** Why no version could be made at all, customer-safe; `null` when one could. */\n refusal: string | null\n}\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/** Make the versions. Exported for the spec, which drives it against a Firestore double. */\nexport async function createAiExperimentVersions(\n firestore: FirebaseFirestore.Firestore,\n input: AiExperimentVersionsInput,\n): Promise<AiExperimentVersionsResult> {\n const now = input.now ?? new Date()\n const screenRef = firestore.collection('hosts').doc(input.hostId).collection('screens').doc(input.screenId)\n const screen = await screenRef.get()\n const empty = (refusal: string): AiExperimentVersionsResult => ({ versions: [], skipped: [], refusal })\n if (!screen.exists || screen.get('deletedAt')) return empty('The page under test no longer exists.')\n const publishedId = str(screen.get('versionId'))\n const source = publishedId ? await screenRef.collection('versions').doc(publishedId).get() : null\n const sourceData = source?.exists ? (source.data() as Record<string, unknown>) : null\n const nodes = sourceData ? decodeStoredNodes<Record<string, unknown>>(sourceData['nodes']) : null\n if (!sourceData || !nodes) return empty('The page has no published version to start from.')\n const rootId = typeof sourceData['rootId'] === 'string' ? sourceData['rootId'] : undefined\n const regionRootId = input.nodeId ?? pageContentRootId(nodes as never, rootId)\n\n const versions: AiExperimentVersionsResult['versions'] = []\n const skipped: AiExperimentVersionsResult['skipped'] = []\n for (const [index, variant] of input.proposal.variants.entries()) {\n // The first variant is the copy as it stands: the control, on the published version.\n if (index === 0) continue\n const versionId = aiExperimentVersionId(input.job.$id, index)\n const name = aiExperimentVersionName(variant.name, index)\n const versionRef = screenRef.collection('versions').doc(versionId)\n const earlier = await versionRef.get()\n if (earlier.exists && !earlier.get('deletedAt')) {\n versions.push({ index, name: str(earlier.get('displayName')) || name, versionId, changed: [] })\n continue\n }\n const result = applyAiExperimentVariantCopy(\n nodes,\n { headline: variant.headline, body: variant.body },\n regionRootId,\n )\n if (!result.changed.length) {\n skipped.push({ index, reason: result.reason ?? 'Nothing on the page took the variant’s copy.' })\n continue\n }\n // Compressed at rest, as every write of a version's nodes is.\n const packed = encodeStoredNodes(result.nodes)\n await versionRef.set({\n ...sourceData,\n nodes: packed ? Buffer.from(packed) : result.nodes,\n displayName: name,\n createdAt: now,\n updatedAt: now,\n createdBy: input.uid,\n })\n versions.push({ index, name, versionId, changed: result.changed })\n }\n return { versions, skipped, refusal: null }\n}\n\nexport async function POST(request: Request): Promise<Response> {\n let body: Record<string, unknown>\n try {\n body = ((await request.json()) ?? {}) as Record<string, unknown>\n } catch {\n body = {}\n }\n const gate = await aiJobsGate(request, String(body['orgId'] ?? ''))\n if (gate instanceof Response) return gate\n const hostId = str(body['hostId'])\n const jobId = str(body['jobId'])\n const screenId = str(body['screenId'])\n const nodeId = str(body['nodeId']) || null\n if (!ID_CHARS.test(hostId) || !ID_CHARS.test(jobId) || !ID_CHARS.test(screenId)) {\n return Response.json({ error: 'Name the site, the page under test and the variants to make' }, { status: 400 })\n }\n if (nodeId !== null && !ID_CHARS.test(nodeId)) {\n return Response.json({ error: 'That is not a section of the page' }, { status: 400 })\n }\n const { firestore, orgId, uid, staff, org, decoded } = gate\n\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n const host = hostSnapshot.exists ? (hostSnapshot.data() as Record<string, unknown>) : null\n if (!host || host['orgId'] !== orgId) {\n return Response.json({ error: 'Unknown site' }, { status: 404 })\n }\n const role = ((host['memberRoles'] ?? {}) as Record<string, unknown>)[uid]\n if (!staff && !hostRoleCanWrite(role)) {\n return Response.json({ error: 'Editing requires the editor role' }, { status: 403 })\n }\n const locked = await lockdownRefusal({\n request,\n staff,\n uid,\n org: org as Record<string, unknown>,\n host: host as never,\n })\n if (locked) return locked\n if (!checkEntitlement(org, 'versioning')) {\n return Response.json({ error: AI_SEO_APPLY_VERSIONING_COPY }, { status: 403 })\n }\n\n const job = await getAiJob(firestore, orgId, jobId)\n if (!job || job.kind !== 'experiment' || job.hostId !== hostId) {\n return Response.json({ error: 'Unknown variants' }, { status: 404 })\n }\n if (job.status !== 'done') {\n return Response.json({ error: 'The variants are still being written' }, { status: 409 })\n }\n const proposal = readAiExperimentVariants(\n (job.outputs ?? []).find((output) => output.resource === 'experiment')?.proposal,\n )\n if (!proposal || proposal.target === 'email') {\n return Response.json({ error: 'These variants are not a page’s or a section’s' }, { status: 409 })\n }\n if (proposal.target === 'section' && !nodeId) {\n return Response.json({ error: 'Pick the section under test first' }, { status: 400 })\n }\n\n const result = await createAiExperimentVersions(firestore, {\n hostId,\n screenId,\n nodeId: proposal.target === 'section' ? nodeId : null,\n job,\n proposal,\n uid,\n })\n if (result.refusal) return Response.json({ error: result.refusal }, { status: 409 })\n const made = result.versions.filter((entry) => entry.changed.length)\n if (made.length) {\n await writeAiJobAudit(firestore, {\n action: 'ai.job.apply',\n actorUid: uid,\n actorEmail: decoded.email ?? null,\n orgId,\n jobId,\n after: { hostId, screenId, versions: made.map((entry) => entry.versionId) },\n })\n }\n return Response.json(result, { status: 200, headers: { 'Cache-Control': 'no-store' } })\n}\n\nexport const dynamic = 'force-dynamic'\n"],"names":["checkEntitlement","encodeStoredNodes","hostRoleCanWrite","pageContentRootId","decodeStoredNodes","lockdownRefusal","getAiJob","writeAiJobAudit","readAiExperimentVariants","applyAiExperimentVariantCopy","AI_SEO_APPLY_VERSIONING_COPY","aiJobsGate","ID_CHARS","aiExperimentVersionId","jobId","index","aiExperimentVersionName","name","label","replace","trim","slice","str","value","createAiExperimentVersions","firestore","input","now","Date","screenRef","collection","doc","hostId","screenId","screen","get","empty","refusal","versions","skipped","exists","publishedId","source","sourceData","data","nodes","rootId","undefined","regionRootId","nodeId","variant","proposal","variants","entries","versionId","job","$id","versionRef","earlier","push","changed","result","headline","body","length","reason","packed","set","Buffer","from","displayName","createdAt","updatedAt","createdBy","uid","POST","request","host","json","gate","String","Response","test","error","status","orgId","staff","org","decoded","hostSnapshot","role","locked","kind","outputs","find","output","resource","target","made","filter","entry","action","actorUid","actorEmail","email","after","map","headers","dynamic"],"mappings":"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"}
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
// The POST climbs `aiGateLadder`, whose lockdown rung is the verdict.
|
|
18
18
|
import { randomUUID } from "crypto";
|
|
19
19
|
import { aiJobAdmissionRefusal, aiJobSiteRefusal } from "../jobs/ai-job-admission.js";
|
|
20
|
-
import { AI_JOB_INLINE_BUDGET_MS, aiJobNextStepMinimumMs, aiJobSummary, getAiJob, resumeAiJob, runAiJobStep, writeAiJobAudit } from "../jobs/ai-jobs.js";
|
|
20
|
+
import { AI_JOB_INLINE_BUDGET_MS, aiJobNextStepMinimumMs, aiJobSummary, getAiJob, aiBuildRetryable, resumeAiJob, retryAiBuildJob, runAiJobStep, writeAiJobAudit } from "../jobs/ai-jobs.js";
|
|
21
|
+
import { aiBuildFreeBalanceRefusal, aiBuildFreeTaste } from "../jobs/ai-job-build-step.js";
|
|
22
|
+
import { aiBuildCreditEstimate, aiBuildRetryLedger, aiBuildUnits, aiLedgerUnits } from "../model/ai-build-job.js";
|
|
21
23
|
import { aiGateLadder } from "../runtime/ai-gate.js";
|
|
22
24
|
import { releaseAssistMessage } from "../usage/assist-usage.js";
|
|
23
25
|
/**
|
|
@@ -83,6 +85,31 @@ export async function POST(request, context) {
|
|
|
83
85
|
status: 400
|
|
84
86
|
});
|
|
85
87
|
}
|
|
88
|
+
// Try again on a build (AGL-3616): its failed items and what they left
|
|
89
|
+
// unbuilt, never what succeeded, and never its plan again.
|
|
90
|
+
const retryFailedItems = (payload == null ? void 0 : payload['retry']) === 'failed-items';
|
|
91
|
+
if (retryFailedItems) {
|
|
92
|
+
const refusal = await aiBuildRetryRefusal(gate, existing).catch((error)=>{
|
|
93
|
+
console.error('ai build retry admission failed', {
|
|
94
|
+
orgId: gate.orgId,
|
|
95
|
+
jobId,
|
|
96
|
+
error
|
|
97
|
+
});
|
|
98
|
+
return {
|
|
99
|
+
status: 500,
|
|
100
|
+
error: 'The AI job could not be resumed'
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
if (refusal) {
|
|
104
|
+
await release();
|
|
105
|
+
return Response.json({
|
|
106
|
+
error: refusal.error,
|
|
107
|
+
job: aiJobSummary(existing, new Date())
|
|
108
|
+
}, {
|
|
109
|
+
status: refusal.status
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
86
113
|
// A confirmed plan runs the step that writes, and an allowance free when the
|
|
87
114
|
// job was created may be used by now (AGL-2909): the kind is asked again
|
|
88
115
|
// before anything runs, and a refusal leaves the job waiting for review.
|
|
@@ -129,9 +156,11 @@ export async function POST(request, context) {
|
|
|
129
156
|
}
|
|
130
157
|
}
|
|
131
158
|
const now = new Date();
|
|
132
|
-
const { job, changed } = await resumeAiJob(gate.firestore, gate.orgId, jobId, {
|
|
159
|
+
const { job, changed } = retryFailedItems ? await retryAiBuildJob(gate.firestore, gate.orgId, jobId, now) : await resumeAiJob(gate.firestore, gate.orgId, jobId, {
|
|
133
160
|
uid: gate.uid
|
|
134
|
-
}, now
|
|
161
|
+
}, now, {
|
|
162
|
+
publishConfirmed: (payload == null ? void 0 : payload['publish']) === true
|
|
163
|
+
});
|
|
135
164
|
if (!changed) {
|
|
136
165
|
await release();
|
|
137
166
|
return Response.json({
|
|
@@ -149,7 +178,7 @@ export async function POST(request, context) {
|
|
|
149
178
|
jobId,
|
|
150
179
|
after: {
|
|
151
180
|
status: job.status,
|
|
152
|
-
reason: (_ref1 = (_existing_review = existing.review) == null ? void 0 : _existing_review.reason) != null ? _ref1 : null,
|
|
181
|
+
reason: retryFailedItems ? 'retry-failed-items' : (_ref1 = (_existing_review = existing.review) == null ? void 0 : _existing_review.reason) != null ? _ref1 : null,
|
|
153
182
|
kind: existing.kind
|
|
154
183
|
}
|
|
155
184
|
});
|
|
@@ -203,6 +232,47 @@ export async function POST(request, context) {
|
|
|
203
232
|
status: 200
|
|
204
233
|
});
|
|
205
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Why a build's Try again cannot run (AGL-3616), or `null`: it is not a
|
|
237
|
+
* finished build, the site switched AI off, or — on the Free taste — the
|
|
238
|
+
* month's Free credits do not cover what the retried items are estimated at.
|
|
239
|
+
*/ async function aiBuildRetryRefusal(gate, job) {
|
|
240
|
+
var _job_hostId;
|
|
241
|
+
var _job_plan, _job_items;
|
|
242
|
+
const plan = ((_job_plan = job.plan) == null ? void 0 : _job_plan.status) === 'confirmed' ? job.plan : null;
|
|
243
|
+
if (!aiBuildRetryable(job) || !plan || !((_job_items = job.items) == null ? void 0 : _job_items.length)) {
|
|
244
|
+
return {
|
|
245
|
+
status: 409,
|
|
246
|
+
error: 'Only a finished build can try its failed items again'
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const site = await aiJobSiteRefusal({
|
|
250
|
+
firestore: gate.firestore,
|
|
251
|
+
org: gate.org,
|
|
252
|
+
hostId: (_job_hostId = job.hostId) != null ? _job_hostId : null
|
|
253
|
+
});
|
|
254
|
+
if (site) return site;
|
|
255
|
+
const { retried } = aiBuildRetryLedger(job.items, job.kind === 'site' ? aiLedgerUnits(job.items) : aiBuildUnits(plan));
|
|
256
|
+
if (!retried.length) return {
|
|
257
|
+
status: 409,
|
|
258
|
+
error: 'Nothing in this build is left to try again'
|
|
259
|
+
};
|
|
260
|
+
// A site's retried parts are its own pages and creations, held by the
|
|
261
|
+
// reservation each pass takes; a build's are estimated against the Free taste.
|
|
262
|
+
if (job.kind === 'site' || !aiBuildFreeTaste(gate.org)) return null;
|
|
263
|
+
const refusal = await aiBuildFreeBalanceRefusal({
|
|
264
|
+
firestore: gate.firestore,
|
|
265
|
+
org: gate.org,
|
|
266
|
+
estimate: aiBuildCreditEstimate(plan, {
|
|
267
|
+
slots: new Set(retried)
|
|
268
|
+
}),
|
|
269
|
+
now: new Date()
|
|
270
|
+
});
|
|
271
|
+
return refusal ? {
|
|
272
|
+
status: 403,
|
|
273
|
+
error: refusal
|
|
274
|
+
} : null;
|
|
275
|
+
}
|
|
206
276
|
export const dynamic = 'force-dynamic';
|
|
207
277
|
export const maxDuration = 60;
|
|
208
278
|
|