@aglyn/plugins-ai 1.0.0-beta.244 → 1.0.0-beta.247

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.
Files changed (173) hide show
  1. package/package.json +17 -17
  2. package/src/lib/activity/ai-activity-actions.js +7 -1
  3. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  4. package/src/lib/components/ai-job-links.js +21 -0
  5. package/src/lib/components/ai-job-links.js.map +1 -1
  6. package/src/lib/components/ai-product-zones.d.ts +3 -1
  7. package/src/lib/components/ai-product-zones.js.map +1 -1
  8. package/src/lib/components/ai-products-hub-card.component.js +1 -1
  9. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  10. package/src/lib/components/ai-products-proposals.component.js +17 -9
  11. package/src/lib/components/ai-products-proposals.component.js.map +1 -1
  12. package/src/lib/components/ai-site-start-card.component.d.ts +1 -1
  13. package/src/lib/components/ai-site-start-card.component.js +98 -23
  14. package/src/lib/components/ai-site-start-card.component.js.map +1 -1
  15. package/src/lib/components/ai-upsell-dialog.component.js +1 -1
  16. package/src/lib/components/ai-upsell-dialog.component.js.map +1 -1
  17. package/src/lib/declarations.server.js +12 -0
  18. package/src/lib/declarations.server.js.map +1 -1
  19. package/src/lib/jobs/ai-build-capabilities.js +216 -2
  20. package/src/lib/jobs/ai-build-capabilities.js.map +1 -1
  21. package/src/lib/jobs/ai-job-build-step.d.ts +3 -1
  22. package/src/lib/jobs/ai-job-build-step.js +124 -18
  23. package/src/lib/jobs/ai-job-build-step.js.map +1 -1
  24. package/src/lib/jobs/ai-job-duplicate-step.d.ts +123 -0
  25. package/src/lib/jobs/ai-job-duplicate-step.js +269 -0
  26. package/src/lib/jobs/ai-job-duplicate-step.js.map +1 -0
  27. package/src/lib/jobs/ai-job-form-dataset.d.ts +8 -5
  28. package/src/lib/jobs/ai-job-form-dataset.js +28 -10
  29. package/src/lib/jobs/ai-job-form-dataset.js.map +1 -1
  30. package/src/lib/jobs/ai-job-form-step.d.ts +20 -4
  31. package/src/lib/jobs/ai-job-form-step.js +98 -28
  32. package/src/lib/jobs/ai-job-form-step.js.map +1 -1
  33. package/src/lib/jobs/ai-job-layout-language.d.ts +6 -2
  34. package/src/lib/jobs/ai-job-layout-language.js +29 -5
  35. package/src/lib/jobs/ai-job-layout-language.js.map +1 -1
  36. package/src/lib/jobs/ai-job-layout-step.js +4 -1
  37. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  38. package/src/lib/jobs/ai-job-media-step.d.ts +239 -0
  39. package/src/lib/jobs/ai-job-media-step.js +610 -0
  40. package/src/lib/jobs/ai-job-media-step.js.map +1 -0
  41. package/src/lib/jobs/ai-job-navigation-step.d.ts +157 -0
  42. package/src/lib/jobs/ai-job-navigation-step.js +439 -0
  43. package/src/lib/jobs/ai-job-navigation-step.js.map +1 -0
  44. package/src/lib/jobs/ai-job-page-language.d.ts +4 -1
  45. package/src/lib/jobs/ai-job-page-language.js +30 -5
  46. package/src/lib/jobs/ai-job-page-language.js.map +1 -1
  47. package/src/lib/jobs/ai-job-page-sections.js +5 -4
  48. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  49. package/src/lib/jobs/ai-job-page-step.js +3 -2
  50. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  51. package/src/lib/jobs/ai-job-plan-step.d.ts +14 -3
  52. package/src/lib/jobs/ai-job-plan-step.js +87 -20
  53. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-products-step.d.ts +2 -2
  55. package/src/lib/jobs/ai-job-products-step.js +2 -2
  56. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  57. package/src/lib/jobs/ai-job-settings-step.d.ts +268 -0
  58. package/src/lib/jobs/ai-job-settings-step.js +690 -0
  59. package/src/lib/jobs/ai-job-settings-step.js.map +1 -0
  60. package/src/lib/jobs/ai-job-site-content.d.ts +22 -8
  61. package/src/lib/jobs/ai-job-site-content.js +33 -15
  62. package/src/lib/jobs/ai-job-site-content.js.map +1 -1
  63. package/src/lib/jobs/ai-job-site-datasets.d.ts +3 -1
  64. package/src/lib/jobs/ai-job-site-datasets.js +13 -5
  65. package/src/lib/jobs/ai-job-site-datasets.js.map +1 -1
  66. package/src/lib/jobs/ai-job-site-events.d.ts +148 -0
  67. package/src/lib/jobs/ai-job-site-events.js +313 -0
  68. package/src/lib/jobs/ai-job-site-events.js.map +1 -0
  69. package/src/lib/jobs/ai-job-site-step.d.ts +12 -4
  70. package/src/lib/jobs/ai-job-site-step.js +132 -26
  71. package/src/lib/jobs/ai-job-site-step.js.map +1 -1
  72. package/src/lib/jobs/ai-job-tracking-step.d.ts +184 -0
  73. package/src/lib/jobs/ai-job-tracking-step.js +501 -0
  74. package/src/lib/jobs/ai-job-tracking-step.js.map +1 -0
  75. package/src/lib/jobs/ai-layout-stock-photos.d.ts +21 -0
  76. package/src/lib/jobs/ai-layout-stock-photos.js +41 -4
  77. package/src/lib/jobs/ai-layout-stock-photos.js.map +1 -1
  78. package/src/lib/jobs/ai-site-publish.d.ts +62 -4
  79. package/src/lib/jobs/ai-site-publish.js +202 -56
  80. package/src/lib/jobs/ai-site-publish.js.map +1 -1
  81. package/src/lib/jobs/ai-stock-photo-picker.d.ts +24 -0
  82. package/src/lib/jobs/ai-stock-photo-picker.js +108 -0
  83. package/src/lib/jobs/ai-stock-photo-picker.js.map +1 -0
  84. package/src/lib/jobs/fixtures/ai-jazz-events-build-plan-recording.d.ts +37 -0
  85. package/src/lib/jobs/fixtures/ai-jazz-events-build-plan-recording.js +47 -0
  86. package/src/lib/jobs/fixtures/ai-jazz-events-build-plan-recording.js.map +1 -0
  87. package/src/lib/jobs/fixtures/ai-stock-photo-agl3760-recordings.d.ts +21 -0
  88. package/src/lib/jobs/fixtures/ai-stock-photo-agl3760-recordings.js +128 -0
  89. package/src/lib/jobs/fixtures/ai-stock-photo-agl3760-recordings.js.map +1 -0
  90. package/src/lib/layout-language/ai-layout-compiler.d.ts +16 -7
  91. package/src/lib/layout-language/ai-layout-compiler.js +62 -42
  92. package/src/lib/layout-language/ai-layout-compiler.js.map +1 -1
  93. package/src/lib/layout-language/ai-layout-frame-hollows.d.ts +41 -0
  94. package/src/lib/layout-language/ai-layout-frame-hollows.js +330 -0
  95. package/src/lib/layout-language/ai-layout-frame-hollows.js.map +1 -0
  96. package/src/lib/layout-language/ai-layout-frame.d.ts +24 -5
  97. package/src/lib/layout-language/ai-layout-frame.js +93 -20
  98. package/src/lib/layout-language/ai-layout-frame.js.map +1 -1
  99. package/src/lib/layout-language/ai-layout-gaps.js +3 -1
  100. package/src/lib/layout-language/ai-layout-gaps.js.map +1 -1
  101. package/src/lib/layout-language/ai-layout-labels.d.ts +70 -0
  102. package/src/lib/layout-language/ai-layout-labels.js +162 -0
  103. package/src/lib/layout-language/ai-layout-labels.js.map +1 -0
  104. package/src/lib/layout-language/ai-layout-links.js +5 -2
  105. package/src/lib/layout-language/ai-layout-links.js.map +1 -1
  106. package/src/lib/layout-language/ai-layout-listings.d.ts +49 -1
  107. package/src/lib/layout-language/ai-layout-listings.js +66 -5
  108. package/src/lib/layout-language/ai-layout-listings.js.map +1 -1
  109. package/src/lib/layout-language/ai-layout-tree.d.ts +2 -0
  110. package/src/lib/layout-language/ai-layout-tree.js +17 -0
  111. package/src/lib/layout-language/ai-layout-tree.js.map +1 -1
  112. package/src/lib/model/ai-build-event-page.d.ts +70 -0
  113. package/src/lib/model/ai-build-event-page.js +145 -0
  114. package/src/lib/model/ai-build-event-page.js.map +1 -0
  115. package/src/lib/model/ai-build-job.d.ts +44 -0
  116. package/src/lib/model/ai-build-job.js +141 -13
  117. package/src/lib/model/ai-build-job.js.map +1 -1
  118. package/src/lib/model/ai-build-plan.d.ts +39 -3
  119. package/src/lib/model/ai-build-plan.js +59 -5
  120. package/src/lib/model/ai-build-plan.js.map +1 -1
  121. package/src/lib/model/ai-credit-estimate.d.ts +52 -0
  122. package/src/lib/model/ai-credit-estimate.js +51 -0
  123. package/src/lib/model/ai-credit-estimate.js.map +1 -1
  124. package/src/lib/model/ai-job-activity.js +6 -1
  125. package/src/lib/model/ai-job-activity.js.map +1 -1
  126. package/src/lib/model/ai-jobs.types.d.ts +18 -2
  127. package/src/lib/model/ai-jobs.types.js +6 -1
  128. package/src/lib/model/ai-jobs.types.js.map +1 -1
  129. package/src/lib/model/ai-layout-menus.d.ts +104 -0
  130. package/src/lib/model/ai-layout-menus.js +518 -0
  131. package/src/lib/model/ai-layout-menus.js.map +1 -0
  132. package/src/lib/model/ai-plan-capabilities.d.ts +8 -0
  133. package/src/lib/model/ai-plan-capabilities.js.map +1 -1
  134. package/src/lib/model/ai-products.d.ts +31 -1
  135. package/src/lib/model/ai-products.js +34 -1
  136. package/src/lib/model/ai-products.js.map +1 -1
  137. package/src/lib/model/ai-site-inventory.d.ts +17 -0
  138. package/src/lib/model/ai-site-inventory.js +5 -0
  139. package/src/lib/model/ai-site-inventory.js.map +1 -1
  140. package/src/lib/model/ai-site-job.d.ts +12 -2
  141. package/src/lib/model/ai-site-job.js +39 -12
  142. package/src/lib/model/ai-site-job.js.map +1 -1
  143. package/src/lib/model/ai-site-store-pages.js +1 -1
  144. package/src/lib/model/ai-site-store-pages.js.map +1 -1
  145. package/src/lib/runtime/ai-doctrine-validators.d.ts +78 -3
  146. package/src/lib/runtime/ai-doctrine-validators.js +219 -31
  147. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  148. package/src/lib/runtime/ai-doctrine.d.ts +6 -1
  149. package/src/lib/runtime/ai-doctrine.js +16 -7
  150. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  151. package/src/lib/runtime/ai-eval-live.d.ts +1 -1
  152. package/src/lib/runtime/ai-eval-live.js +1 -1
  153. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  154. package/src/lib/runtime/ai-palette.d.ts +3 -1
  155. package/src/lib/runtime/ai-palette.generated.js +499 -11
  156. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  157. package/src/lib/runtime/ai-palette.js +5 -3
  158. package/src/lib/runtime/ai-palette.js.map +1 -1
  159. package/src/lib/runtime/ai-products-generation.d.ts +4 -2
  160. package/src/lib/runtime/ai-products-generation.js +12 -6
  161. package/src/lib/runtime/ai-products-generation.js.map +1 -1
  162. package/src/lib/runtime/ai-template-examples.js +8 -2
  163. package/src/lib/runtime/ai-template-examples.js.map +1 -1
  164. package/src/lib/runtime/site-inventory.d.ts +12 -0
  165. package/src/lib/runtime/site-inventory.js +29 -1
  166. package/src/lib/runtime/site-inventory.js.map +1 -1
  167. package/src/lib/server.js +20 -0
  168. package/src/lib/server.js.map +1 -1
  169. package/src/lib/testing/stand-in-dataset-index.js +37 -3
  170. package/src/lib/testing/stand-in-dataset-index.js.map +1 -1
  171. package/src/lib/tools/ai-products-tool.d.ts +3 -1
  172. package/src/lib/tools/ai-products-tool.js +15 -5
  173. package/src/lib/tools/ai-products-tool.js.map +1 -1
@@ -0,0 +1,269 @@
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 { DUPLICABLE_RESOURCE_NOUNS } from "@aglyn/aglyn/app-utils/duplicate-resource";
18
+ import { hostRoleCanWrite } from "@aglyn/aglyn/app-utils/organizations";
19
+ import { duplicateResource, recipeFor } from "@aglyn/tenant-data-admin/server/duplicate-resource";
20
+ import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
21
+ import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
22
+ import { isAiPlanNewRef } from "../model/ai-build-plan.js";
23
+ import { parseAiDuplicateResourceInput } from "../tools/ai-duplicate-resource-tool.js";
24
+ import { registerAiJobAdmission } from "./ai-job-admission.js";
25
+ import { aiJobEditModel } from "./ai-job-edit-step.js";
26
+ import { aiLimitReview, aiUnspentOutcome } from "./ai-job-generation.js";
27
+ import { registerAiJobStep } from "./ai-jobs.js";
28
+ /**
29
+ * The `duplicate` step (AGL-3740): copying something the site already has —
30
+ * a page, a component, a layout, a template, a form, an email design or a
31
+ * workflow — as a first-class build operation, so "make another page like
32
+ * Services" copies instead of regenerating.
33
+ *
34
+ * NO MODEL. The copy is core's `duplicateResource`, the very function the
35
+ * console's Duplicate… row menus and `/api/hosts/resources` (`action:
36
+ * 'duplicate'`) call, and the one the `duplicate_resource` tool wraps: the
37
+ * same fields carried, the same uniqueness rules, the same plan counters met
38
+ * inside the transaction, the same `*.duplicated` activity row. So it costs
39
+ * no credits, and it can copy nothing the console could not.
40
+ *
41
+ * DRAFTS ONLY, by construction of the copy: a page copy has no address, a
42
+ * workflow copy no trigger, and nothing places a component, layout or form
43
+ * copy until a person does.
44
+ *
45
+ * ── Who may ──────────────────────────────────────────────────────────────
46
+ *
47
+ * The console door's rule: a member with a write role on the site (admin,
48
+ * editor or author), while no lockdown freezes the site, its org or the
49
+ * platform. `ai.generate` is the jobs door's own check. Asked at the door
50
+ * and again before the copy.
51
+ *
52
+ * ── Once per job ─────────────────────────────────────────────────────────
53
+ *
54
+ * The job's id is the copy's attempt key, so a pass asked again answers with
55
+ * the copy the first made rather than making a second.
56
+ */ export const AI_DUPLICATE_KIND = 'duplicate';
57
+ /**
58
+ * ASSUMED: the least time the step needs — the site, the org, the source's
59
+ * collection when it is named by name, and the one transaction that copies.
60
+ * No model, so it fits an inline door.
61
+ */ export const AI_JOB_DUPLICATE_STEP_MINIMUM_MS = 5000;
62
+ export const AI_DUPLICATE_NO_SITE_COPY = 'Open the site whose content should be copied.';
63
+ export const AI_DUPLICATE_ROLE_COPY = 'Copying on this site needs the admin, editor or author role on it.';
64
+ export const AI_DUPLICATE_LOCKED_COPY = 'This site is not accepting changes right now.';
65
+ export const AI_DUPLICATE_UNREADABLE_COPY = 'The request did not name something on this site to copy.';
66
+ export const AI_DUPLICATE_NOT_YET_COPY = 'Only something the site already has can be copied, not something this build is making.';
67
+ /** The output resource each duplicable kind is opened as. */ export const AI_DUPLICATE_OUTPUT_RESOURCES = {
68
+ screen: 'screen',
69
+ component: 'reusableComponent',
70
+ layout: 'layout',
71
+ template: 'template',
72
+ form: 'form',
73
+ emailDesign: 'emailScreen',
74
+ workflow: 'workflow'
75
+ };
76
+ /** The copy's attempt key: one copy per job, however often it is asked. */ export function aiDuplicateAttemptKey(job) {
77
+ return `ai-job:${job.$id}`.slice(0, 200);
78
+ }
79
+ /** What the plan's item asks, read; a refusal in words when it is unreadable. */ export function aiDuplicateRequestOf(inputs) {
80
+ var _parseAiDuplicateResourceInput;
81
+ const source = typeof (inputs == null ? void 0 : inputs['source']) === 'string' ? inputs['source'].trim() : '';
82
+ if (isAiPlanNewRef(source)) return AI_DUPLICATE_NOT_YET_COPY;
83
+ const name = typeof (inputs == null ? void 0 : inputs['name']) === 'string' ? inputs['name'] : null;
84
+ return (_parseAiDuplicateResourceInput = parseAiDuplicateResourceInput({
85
+ kind: inputs == null ? void 0 : inputs['kind'],
86
+ sourceId: source,
87
+ name
88
+ })) != null ? _parseAiDuplicateResourceInput : AI_DUPLICATE_UNREADABLE_COPY;
89
+ }
90
+ /** The rows of a kind's collection on a site. */ export async function readAiDuplicateSourceRows(firestore, hostId, kind) {
91
+ const recipe = recipeFor(kind);
92
+ if (!recipe) return [];
93
+ const snapshot = await firestore.collection('hosts').doc(hostId).collection(recipe.collection).select(recipe.nameField, 'kind', 'deletedAt').get();
94
+ return snapshot.docs.map((doc)=>{
95
+ var _doc_get;
96
+ return {
97
+ id: doc.id,
98
+ name: String((_doc_get = doc.get(recipe.nameField)) != null ? _doc_get : ''),
99
+ kind: doc.get('kind'),
100
+ deleted: doc.get('deletedAt') != null
101
+ };
102
+ });
103
+ }
104
+ /** Whether a row of a shared collection is of this kind: pages and email designs share `screens`. */ function rowIsKind(kind, row) {
105
+ if (kind === 'emailDesign') return row.kind === 'email';
106
+ if (kind === 'screen') return row.kind !== 'email';
107
+ return true;
108
+ }
109
+ /**
110
+ * The source's id: the row whose id it names, else the one row whose name it
111
+ * is (case aside) — so "copy the Welcome email" works where the inventory
112
+ * lists no email designs. Two rows of one name, or none, are refused in words.
113
+ */ export function aiDuplicateSourceId(kind, source, rows) {
114
+ const noun = DUPLICABLE_RESOURCE_NOUNS[kind];
115
+ const live = rows.filter((row)=>!row.deleted && rowIsKind(kind, row));
116
+ if (live.some((row)=>row.id === source)) return {
117
+ id: source
118
+ };
119
+ const wanted = source.trim().toLowerCase();
120
+ const named = live.filter((row)=>row.name.trim().toLowerCase() === wanted);
121
+ if (named.length === 1) return {
122
+ id: named[0].id
123
+ };
124
+ if (named.length > 1) return {
125
+ error: `This site has more than one ${noun} called “${source}”; name the one to copy.`
126
+ };
127
+ return {
128
+ error: `This site has no ${noun} “${source}” to copy.`
129
+ };
130
+ }
131
+ /** Why this member may not copy on this site now, or `null`. */ export async function aiDuplicateAccessRefusal(input, deps = {}) {
132
+ var _input_host_get, _deps_lockdown;
133
+ if (!input.host.exists) return 'Unknown site';
134
+ const roles = (_input_host_get = input.host.get('memberRoles')) != null ? _input_host_get : {};
135
+ if (!hostRoleCanWrite(roles[input.uid])) return AI_DUPLICATE_ROLE_COPY;
136
+ const locked = await ((_deps_lockdown = deps.lockdown) != null ? _deps_lockdown : getLockdownVerdict)({
137
+ staff: false,
138
+ uid: input.uid,
139
+ org: input.org,
140
+ host: input.host.data(),
141
+ intent: 'write'
142
+ });
143
+ return locked ? AI_DUPLICATE_LOCKED_COPY : null;
144
+ }
145
+ /** The copy's output note: what it is and what it waits for. */ export function aiDuplicateOutputNote(kind) {
146
+ const noun = DUPLICABLE_RESOURCE_NOUNS[kind];
147
+ return `A copy of the ${noun}, saved as a draft: nothing on the live site uses it until you do.`;
148
+ }
149
+ /** The request read and its source found, or a refusal in words. */ async function duplicatePlanOf(firestore, job, readRows) {
150
+ if (!job.hostId) return AI_DUPLICATE_NO_SITE_COPY;
151
+ const request = aiDuplicateRequestOf(job.inputs);
152
+ if (typeof request === 'string') return request;
153
+ const found = aiDuplicateSourceId(request.kind, request.sourceId, await readRows(firestore, job.hostId, request.kind));
154
+ if ('error' in found) return found.error;
155
+ return _extends({}, request, {
156
+ id: found.id
157
+ });
158
+ }
159
+ export function createAiJobDuplicateStep(deps = {}) {
160
+ var _deps_duplicate, _deps_readRows;
161
+ const duplicate = (_deps_duplicate = deps.duplicate) != null ? _deps_duplicate : duplicateResource;
162
+ const readRows = (_deps_readRows = deps.readRows) != null ? _deps_readRows : readAiDuplicateSourceRows;
163
+ return async ({ job, firestore, org, modelFor })=>{
164
+ var _ref, _data;
165
+ // No model is asked: the outcome records the edit step's at no cost.
166
+ const model = (_ref = modelFor == null ? void 0 : modelFor('job.edit')) != null ? _ref : aiJobEditModel();
167
+ const unspent = (failure)=>_extends({}, aiUnspentOutcome(model), {
168
+ failure
169
+ });
170
+ const hostId = job.hostId;
171
+ if (!hostId) return unspent(AI_DUPLICATE_NO_SITE_COPY);
172
+ const plan = await duplicatePlanOf(firestore, job, readRows);
173
+ if (typeof plan === 'string') return unspent(plan);
174
+ const orgData = org != null ? org : (_data = (await firestore.collection('orgs').doc(job.orgId).get()).data()) != null ? _data : null;
175
+ const host = await firestore.collection('hosts').doc(hostId).get();
176
+ const refused = await aiDuplicateAccessRefusal({
177
+ host,
178
+ uid: job.createdBy,
179
+ org: orgData
180
+ }, {
181
+ lockdown: deps.lockdown
182
+ });
183
+ if (refused) return aiUnspentOutcome(model, {
184
+ review: aiLimitReview(refused)
185
+ });
186
+ const result = await duplicate(plan.kind, {
187
+ orgId: job.orgId,
188
+ hostId,
189
+ sourceId: plan.id,
190
+ name: plan.name,
191
+ uid: job.createdBy,
192
+ email: null,
193
+ org: orgData,
194
+ attemptKey: aiDuplicateAttemptKey(job)
195
+ });
196
+ if (result.ok === false) {
197
+ // A plan counter or a feature the plan lacks is a limit to relay, not a fault.
198
+ return result.status === 403 ? aiUnspentOutcome(model, {
199
+ review: aiLimitReview(result.error)
200
+ }) : unspent(result.error);
201
+ }
202
+ const output = {
203
+ resource: AI_DUPLICATE_OUTPUT_RESOURCES[plan.kind],
204
+ id: result.id,
205
+ versionId: result.versionId,
206
+ hostId,
207
+ hostSubdomain: typeof host.get('subdomain') === 'string' && host.get('subdomain') || null,
208
+ label: result.name,
209
+ note: aiDuplicateOutputNote(plan.kind)
210
+ };
211
+ return aiUnspentOutcome(model, {
212
+ outputs: [
213
+ output
214
+ ]
215
+ });
216
+ };
217
+ }
218
+ export const runAiJobDuplicateStep = createAiJobDuplicateStep();
219
+ /**
220
+ * A copy is admitted on a site of the job's own org, for a member who may
221
+ * write its content now, of a source the site holds. Refused in those words
222
+ * before anything exists; what the plan's counters allow is the copy's own
223
+ * transaction to say.
224
+ */ export function createAiDuplicateJobAdmission(deps = {}) {
225
+ var _deps_ownerOf, _deps_readRows;
226
+ const ownerOf = (_deps_ownerOf = deps.ownerOf) != null ? _deps_ownerOf : resolveOrgIdForHost;
227
+ const readRows = (_deps_readRows = deps.readRows) != null ? _deps_readRows : readAiDuplicateSourceRows;
228
+ return async (context)=>{
229
+ var _context_uid;
230
+ if (!context.hostId) return {
231
+ status: 400,
232
+ error: AI_DUPLICATE_NO_SITE_COPY
233
+ };
234
+ const owner = await ownerOf(context.hostId);
235
+ if (!owner || owner !== context.orgId) return {
236
+ status: 404,
237
+ error: 'Unknown site'
238
+ };
239
+ const host = await context.firestore.collection('hosts').doc(context.hostId).get();
240
+ const refused = await aiDuplicateAccessRefusal({
241
+ host,
242
+ uid: (_context_uid = context.uid) != null ? _context_uid : '',
243
+ org: context.org
244
+ }, {
245
+ lockdown: deps.lockdown
246
+ });
247
+ if (refused) return {
248
+ status: 403,
249
+ error: refused
250
+ };
251
+ const plan = await duplicatePlanOf(context.firestore, {
252
+ hostId: context.hostId,
253
+ inputs: context.inputs
254
+ }, readRows);
255
+ return typeof plan === 'string' ? {
256
+ status: 400,
257
+ error: plan
258
+ } : null;
259
+ };
260
+ }
261
+ export const aiDuplicateJobAdmission = createAiDuplicateJobAdmission();
262
+ /** Registers the duplicate step and its door (AGL-3740). */ export function registerAiDuplicateJob() {
263
+ registerAiJobStep(AI_DUPLICATE_KIND, runAiJobDuplicateStep, {
264
+ minimumMs: AI_JOB_DUPLICATE_STEP_MINIMUM_MS
265
+ });
266
+ registerAiJobAdmission(AI_DUPLICATE_KIND, aiDuplicateJobAdmission);
267
+ }
268
+
269
+ //# sourceMappingURL=ai-job-duplicate-step.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-job-duplicate-step.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 {\n DUPLICABLE_RESOURCE_NOUNS,\n type DuplicableHostResourceKind,\n} from '@aglyn/aglyn/app-utils/duplicate-resource'\nimport { hostRoleCanWrite } from '@aglyn/aglyn/app-utils/organizations'\nimport { duplicateResource, recipeFor } from '@aglyn/tenant-data-admin/server/duplicate-resource'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport { isAiPlanNewRef } from '../model/ai-build-plan'\nimport type { AiJob, AiJobOutput, AiJobOutputResource } from '../model/ai-jobs.types'\nimport { parseAiDuplicateResourceInput, type AiDuplicateResourceInput } from '../tools/ai-duplicate-resource-tool'\nimport { registerAiJobAdmission, type AiJobAdmission, type AiJobAdmissionRefusal } from './ai-job-admission'\nimport { aiJobEditModel } from './ai-job-edit-step'\nimport { aiLimitReview, aiUnspentOutcome } from './ai-job-generation'\nimport type { AiJobStepOutcome, AiJobStepRunner } from './ai-job-text-step'\nimport { registerAiJobStep } from './ai-jobs'\n\n/**\n * The `duplicate` step (AGL-3740): copying something the site already has —\n * a page, a component, a layout, a template, a form, an email design or a\n * workflow — as a first-class build operation, so \"make another page like\n * Services\" copies instead of regenerating.\n *\n * NO MODEL. The copy is core's `duplicateResource`, the very function the\n * console's Duplicate… row menus and `/api/hosts/resources` (`action:\n * 'duplicate'`) call, and the one the `duplicate_resource` tool wraps: the\n * same fields carried, the same uniqueness rules, the same plan counters met\n * inside the transaction, the same `*.duplicated` activity row. So it costs\n * no credits, and it can copy nothing the console could not.\n *\n * DRAFTS ONLY, by construction of the copy: a page copy has no address, a\n * workflow copy no trigger, and nothing places a component, layout or form\n * copy until a person does.\n *\n * ── Who may ──────────────────────────────────────────────────────────────\n *\n * The console door's rule: a member with a write role on the site (admin,\n * editor or author), while no lockdown freezes the site, its org or the\n * platform. `ai.generate` is the jobs door's own check. Asked at the door\n * and again before the copy.\n *\n * ── Once per job ─────────────────────────────────────────────────────────\n *\n * The job's id is the copy's attempt key, so a pass asked again answers with\n * the copy the first made rather than making a second.\n */\n\nexport const AI_DUPLICATE_KIND = 'duplicate' as const\n\n/**\n * ASSUMED: the least time the step needs — the site, the org, the source's\n * collection when it is named by name, and the one transaction that copies.\n * No model, so it fits an inline door.\n */\nexport const AI_JOB_DUPLICATE_STEP_MINIMUM_MS = 5_000\n\nexport const AI_DUPLICATE_NO_SITE_COPY = 'Open the site whose content should be copied.'\nexport const AI_DUPLICATE_ROLE_COPY = 'Copying on this site needs the admin, editor or author role on it.'\nexport const AI_DUPLICATE_LOCKED_COPY = 'This site is not accepting changes right now.'\nexport const AI_DUPLICATE_UNREADABLE_COPY = 'The request did not name something on this site to copy.'\nexport const AI_DUPLICATE_NOT_YET_COPY = 'Only something the site already has can be copied, not something this build is making.'\n\n/** The output resource each duplicable kind is opened as. */\nexport const AI_DUPLICATE_OUTPUT_RESOURCES: Readonly<Record<DuplicableHostResourceKind, AiJobOutputResource>> = {\n screen: 'screen',\n component: 'reusableComponent',\n layout: 'layout',\n template: 'template',\n form: 'form',\n emailDesign: 'emailScreen',\n workflow: 'workflow',\n}\n\n/** The copy's attempt key: one copy per job, however often it is asked. */\nexport function aiDuplicateAttemptKey(job: Pick<AiJob, '$id'>): string {\n return `ai-job:${job.$id}`.slice(0, 200)\n}\n\n/** What the plan's item asks, read; a refusal in words when it is unreadable. */\nexport function aiDuplicateRequestOf(inputs: Readonly<Record<string, unknown>> | null | undefined): AiDuplicateResourceInput | string {\n const source = typeof inputs?.['source'] === 'string' ? inputs['source'].trim() : ''\n if (isAiPlanNewRef(source)) return AI_DUPLICATE_NOT_YET_COPY\n const name = typeof inputs?.['name'] === 'string' ? inputs['name'] : null\n return parseAiDuplicateResourceInput({ kind: inputs?.['kind'], sourceId: source, name }) ?? AI_DUPLICATE_UNREADABLE_COPY\n}\n\n/** One row of a kind's collection, as a source is looked up by. */\nexport interface AiDuplicateSourceRow {\n id: string\n name: string\n kind: unknown\n deleted: boolean\n}\n\n/** The rows of a kind's collection on a site. */\nexport async function readAiDuplicateSourceRows(\n firestore: Firestore,\n hostId: string,\n kind: DuplicableHostResourceKind,\n): Promise<AiDuplicateSourceRow[]> {\n const recipe = recipeFor(kind)\n if (!recipe) return []\n const snapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection(recipe.collection)\n .select(recipe.nameField, 'kind', 'deletedAt')\n .get()\n return snapshot.docs.map((doc) => ({\n id: doc.id,\n name: String(doc.get(recipe.nameField) ?? ''),\n kind: doc.get('kind'),\n deleted: doc.get('deletedAt') != null,\n }))\n}\n\n/** Whether a row of a shared collection is of this kind: pages and email designs share `screens`. */\nfunction rowIsKind(kind: DuplicableHostResourceKind, row: AiDuplicateSourceRow): boolean {\n if (kind === 'emailDesign') return row.kind === 'email'\n if (kind === 'screen') return row.kind !== 'email'\n return true\n}\n\n/**\n * The source's id: the row whose id it names, else the one row whose name it\n * is (case aside) — so \"copy the Welcome email\" works where the inventory\n * lists no email designs. Two rows of one name, or none, are refused in words.\n */\nexport function aiDuplicateSourceId(\n kind: DuplicableHostResourceKind,\n source: string,\n rows: readonly AiDuplicateSourceRow[],\n): { id: string } | { error: string } {\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n const live = rows.filter((row) => !row.deleted && rowIsKind(kind, row))\n if (live.some((row) => row.id === source)) return { id: source }\n const wanted = source.trim().toLowerCase()\n const named = live.filter((row) => row.name.trim().toLowerCase() === wanted)\n if (named.length === 1) return { id: named[0].id }\n if (named.length > 1) return { error: `This site has more than one ${noun} called “${source}”; name the one to copy.` }\n return { error: `This site has no ${noun} “${source}” to copy.` }\n}\n\n/** Why this member may not copy on this site now, or `null`. */\nexport async function aiDuplicateAccessRefusal(\n input: { host: FirebaseFirestore.DocumentSnapshot; uid: string; org: object | null },\n deps: { lockdown?: typeof getLockdownVerdict } = {},\n): Promise<string | null> {\n if (!input.host.exists) return 'Unknown site'\n const roles = (input.host.get('memberRoles') ?? {}) as Record<string, unknown>\n if (!hostRoleCanWrite(roles[input.uid])) return AI_DUPLICATE_ROLE_COPY\n const locked = await (deps.lockdown ?? getLockdownVerdict)({\n staff: false,\n uid: input.uid,\n org: input.org as never,\n host: input.host.data() as never,\n intent: 'write',\n })\n return locked ? AI_DUPLICATE_LOCKED_COPY : null\n}\n\n/** The copy's output note: what it is and what it waits for. */\nexport function aiDuplicateOutputNote(kind: DuplicableHostResourceKind): string {\n const noun = DUPLICABLE_RESOURCE_NOUNS[kind]\n return `A copy of the ${noun}, saved as a draft: nothing on the live site uses it until you do.`\n}\n\nexport interface AiJobDuplicateStepDeps {\n duplicate?: typeof duplicateResource\n readRows?: typeof readAiDuplicateSourceRows\n lockdown?: typeof getLockdownVerdict\n}\n\n/** The request read and its source found, or a refusal in words. */\nasync function duplicatePlanOf(\n firestore: Firestore,\n job: Pick<AiJob, 'hostId' | 'inputs'>,\n readRows: typeof readAiDuplicateSourceRows,\n): Promise<(AiDuplicateResourceInput & { id: string }) | string> {\n if (!job.hostId) return AI_DUPLICATE_NO_SITE_COPY\n const request = aiDuplicateRequestOf(job.inputs)\n if (typeof request === 'string') return request\n const found = aiDuplicateSourceId(request.kind, request.sourceId, await readRows(firestore, job.hostId, request.kind))\n if ('error' in found) return found.error\n return { ...request, id: found.id }\n}\n\nexport function createAiJobDuplicateStep(deps: AiJobDuplicateStepDeps = {}): AiJobStepRunner {\n const duplicate = deps.duplicate ?? duplicateResource\n const readRows = deps.readRows ?? readAiDuplicateSourceRows\n return async ({ job, firestore, org, modelFor }): Promise<AiJobStepOutcome> => {\n // No model is asked: the outcome records the edit step's at no cost.\n const model = modelFor?.('job.edit') ?? aiJobEditModel()\n const unspent = (failure: string): AiJobStepOutcome => ({ ...aiUnspentOutcome(model), failure })\n const hostId = job.hostId\n if (!hostId) return unspent(AI_DUPLICATE_NO_SITE_COPY)\n const plan = await duplicatePlanOf(firestore, job, readRows)\n if (typeof plan === 'string') return unspent(plan)\n const orgData = (org ?? ((await firestore.collection('orgs').doc(job.orgId).get()).data() ?? null)) as Record<string, unknown> | null\n const host = await firestore.collection('hosts').doc(hostId).get()\n const refused = await aiDuplicateAccessRefusal({ host, uid: job.createdBy, org: orgData }, { lockdown: deps.lockdown })\n if (refused) return aiUnspentOutcome(model, { review: aiLimitReview(refused) })\n const result = await duplicate(plan.kind, {\n orgId: job.orgId,\n hostId,\n sourceId: plan.id,\n name: plan.name,\n uid: job.createdBy,\n email: null,\n org: orgData,\n attemptKey: aiDuplicateAttemptKey(job),\n })\n if (result.ok === false) {\n // A plan counter or a feature the plan lacks is a limit to relay, not a fault.\n return result.status === 403 ? aiUnspentOutcome(model, { review: aiLimitReview(result.error) }) : unspent(result.error)\n }\n const output: AiJobOutput = {\n resource: AI_DUPLICATE_OUTPUT_RESOURCES[plan.kind],\n id: result.id,\n versionId: result.versionId,\n hostId,\n hostSubdomain: (typeof host.get('subdomain') === 'string' && host.get('subdomain')) || null,\n label: result.name,\n note: aiDuplicateOutputNote(plan.kind),\n }\n return aiUnspentOutcome(model, { outputs: [output] })\n }\n}\n\nexport const runAiJobDuplicateStep = createAiJobDuplicateStep()\n\n// ── Admission ─────────────────────────────────────────────────────────────\n\nexport interface AiDuplicateJobAdmissionDeps {\n ownerOf?: typeof resolveOrgIdForHost\n readRows?: typeof readAiDuplicateSourceRows\n lockdown?: typeof getLockdownVerdict\n}\n\n/**\n * A copy is admitted on a site of the job's own org, for a member who may\n * write its content now, of a source the site holds. Refused in those words\n * before anything exists; what the plan's counters allow is the copy's own\n * transaction to say.\n */\nexport function createAiDuplicateJobAdmission(deps: AiDuplicateJobAdmissionDeps = {}): AiJobAdmission {\n const ownerOf = deps.ownerOf ?? resolveOrgIdForHost\n const readRows = deps.readRows ?? readAiDuplicateSourceRows\n return async (context): Promise<AiJobAdmissionRefusal | null> => {\n if (!context.hostId) return { status: 400, error: AI_DUPLICATE_NO_SITE_COPY }\n const owner = await ownerOf(context.hostId)\n if (!owner || owner !== context.orgId) return { status: 404, error: 'Unknown site' }\n const host = await context.firestore.collection('hosts').doc(context.hostId).get()\n const refused = await aiDuplicateAccessRefusal({ host, uid: context.uid ?? '', org: context.org }, { lockdown: deps.lockdown })\n if (refused) return { status: 403, error: refused }\n const plan = await duplicatePlanOf(context.firestore as Firestore, { hostId: context.hostId, inputs: context.inputs as Record<string, unknown> }, readRows)\n return typeof plan === 'string' ? { status: 400, error: plan } : null\n }\n}\n\nexport const aiDuplicateJobAdmission = createAiDuplicateJobAdmission()\n\n/** Registers the duplicate step and its door (AGL-3740). */\nexport function registerAiDuplicateJob(): void {\n registerAiJobStep(AI_DUPLICATE_KIND, runAiJobDuplicateStep, { minimumMs: AI_JOB_DUPLICATE_STEP_MINIMUM_MS })\n registerAiJobAdmission(AI_DUPLICATE_KIND, aiDuplicateJobAdmission)\n}\n"],"names":["DUPLICABLE_RESOURCE_NOUNS","hostRoleCanWrite","duplicateResource","recipeFor","getLockdownVerdict","resolveOrgIdForHost","isAiPlanNewRef","parseAiDuplicateResourceInput","registerAiJobAdmission","aiJobEditModel","aiLimitReview","aiUnspentOutcome","registerAiJobStep","AI_DUPLICATE_KIND","AI_JOB_DUPLICATE_STEP_MINIMUM_MS","AI_DUPLICATE_NO_SITE_COPY","AI_DUPLICATE_ROLE_COPY","AI_DUPLICATE_LOCKED_COPY","AI_DUPLICATE_UNREADABLE_COPY","AI_DUPLICATE_NOT_YET_COPY","AI_DUPLICATE_OUTPUT_RESOURCES","screen","component","layout","template","form","emailDesign","workflow","aiDuplicateAttemptKey","job","$id","slice","aiDuplicateRequestOf","inputs","source","trim","name","kind","sourceId","readAiDuplicateSourceRows","firestore","hostId","recipe","snapshot","collection","doc","select","nameField","get","docs","map","id","String","deleted","rowIsKind","row","aiDuplicateSourceId","rows","noun","live","filter","some","wanted","toLowerCase","named","length","error","aiDuplicateAccessRefusal","input","deps","host","exists","roles","uid","locked","lockdown","staff","org","data","intent","aiDuplicateOutputNote","duplicatePlanOf","readRows","request","found","createAiJobDuplicateStep","duplicate","modelFor","model","unspent","failure","plan","orgData","orgId","refused","createdBy","review","result","email","attemptKey","ok","status","output","resource","versionId","hostSubdomain","label","note","outputs","runAiJobDuplicateStep","createAiDuplicateJobAdmission","ownerOf","context","owner","aiDuplicateJobAdmission","registerAiDuplicateJob","minimumMs"],"mappings":"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"}
@@ -28,7 +28,7 @@ import type { AiSiteInventory } from '../model/ai-site-inventory';
28
28
  * first — a site start's dataset unit, a build's dataset item, or one the
29
29
  * site already has — and the form step binds the form it generated the way a
30
30
  * person binds one on the Form element: the form node's `datasetId`, and
31
- * each Form Field's `datasetFieldId` where the dataset has a field of the
31
+ * each answer element's `datasetFieldId` where the dataset has a field of the
32
32
  * same name and a type the submitted value can be stored as. A field the
33
33
  * dataset lacks is left unbound, as the Form element's mapping leaves one:
34
34
  * its value stays in the Inbox copy, and a form never adds a field to a
@@ -71,10 +71,13 @@ export interface AiFormDatasetBinding {
71
71
  }
72
72
  /**
73
73
  * Binds a form design to a dataset, in place: the form node's `datasetId`,
74
- * and `datasetFieldId` on each Form Field the dataset has a field for, by its
75
- * `fieldName` or its label, each dataset field taken once. The platform's
76
- * consent field is never bound. A binding the design already carried is
77
- * replaced, so what the model drew never chooses where a record lands.
74
+ * and `datasetFieldId` on each answer element (an Input, Select, Radio Group,
75
+ * Checkbox or Rating) the dataset has a field for, by the name it submits
76
+ * under or its label — the Form Label or Choice Label beside it — each
77
+ * dataset field taken once. Checkboxes sharing a name are one answer and are
78
+ * bound together. The platform's consent field is never bound. A binding the
79
+ * design already carried is replaced, so what the model drew never chooses
80
+ * where a record lands.
78
81
  */
79
82
  export declare function aiBindFormToDataset(nodes: NodesMap, formNodeId: string, dataset: AiFormDataset): AiFormDatasetBinding;
80
83
  export interface AiFormDatasetReadDeps {
@@ -15,7 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
18
- import { collectFormFieldNodeIds, FORM_COMPONENT_ID, isMarketingConsentFieldName } from "@aglyn/aglyn/app-utils/forms";
18
+ import { collectFormFieldNodeIds, FORM_COMPONENT_ID, formFieldDeclsFromNodes, formInputName, formInputType, isMarketingConsentFieldName } from "@aglyn/aglyn/app-utils/forms";
19
19
  import { pluginResourceDraftWriter } from "@aglyn/aglyn/plugin-manager/plugin-resource-drafts";
20
20
  import { AI_DATASET_DRAFT_RESOURCE } from "./ai-build-unit-outcome.js";
21
21
  /**
@@ -29,7 +29,7 @@ import { AI_DATASET_DRAFT_RESOURCE } from "./ai-build-unit-outcome.js";
29
29
  * first — a site start's dataset unit, a build's dataset item, or one the
30
30
  * site already has — and the form step binds the form it generated the way a
31
31
  * person binds one on the Form element: the form node's `datasetId`, and
32
- * each Form Field's `datasetFieldId` where the dataset has a field of the
32
+ * each answer element's `datasetFieldId` where the dataset has a field of the
33
33
  * same name and a type the submitted value can be stored as. A field the
34
34
  * dataset lacks is left unbound, as the Form element's mapping leaves one:
35
35
  * its value stays in the Inbox copy, and a form never adds a field to a
@@ -64,10 +64,13 @@ const NUMBER_TYPES = new Set([
64
64
  }
65
65
  /**
66
66
  * Binds a form design to a dataset, in place: the form node's `datasetId`,
67
- * and `datasetFieldId` on each Form Field the dataset has a field for, by its
68
- * `fieldName` or its label, each dataset field taken once. The platform's
69
- * consent field is never bound. A binding the design already carried is
70
- * replaced, so what the model drew never chooses where a record lands.
67
+ * and `datasetFieldId` on each answer element (an Input, Select, Radio Group,
68
+ * Checkbox or Rating) the dataset has a field for, by the name it submits
69
+ * under or its label — the Form Label or Choice Label beside it — each
70
+ * dataset field taken once. Checkboxes sharing a name are one answer and are
71
+ * bound together. The platform's consent field is never bound. A binding the
72
+ * design already carried is replaced, so what the model drew never chooses
73
+ * where a record lands.
71
74
  */ export function aiBindFormToDataset(nodes, formNodeId, dataset) {
72
75
  var _form_props;
73
76
  const result = {
@@ -80,26 +83,41 @@ const NUMBER_TYPES = new Set([
80
83
  delete props['datasetName'];
81
84
  props['datasetId'] = dataset.id;
82
85
  form.props = props;
86
+ const labels = new Map(formFieldDeclsFromNodes(nodes, formNodeId).map((decl)=>{
87
+ var _decl_label;
88
+ return [
89
+ decl.fieldName,
90
+ (_decl_label = decl.label) != null ? _decl_label : ''
91
+ ];
92
+ }));
83
93
  const taken = new Set();
94
+ /** The dataset field each answer's name was bound to, or `null` where none was. */ const byName = new Map();
84
95
  for (const id of collectFormFieldNodeIds(nodes, formNodeId)){
85
- var _field_props, _fieldProps_fieldName, _fieldProps_label, _fieldProps_fieldType, _matching;
96
+ var _field_props, _labels_get, _matching;
86
97
  const field = nodes[id];
87
98
  if (!field) continue;
88
99
  const fieldProps = _extends({}, (_field_props = field.props) != null ? _field_props : {});
89
100
  delete fieldProps['datasetFieldId'];
90
101
  field.props = fieldProps;
91
- const fieldName = String((_fieldProps_fieldName = fieldProps['fieldName']) != null ? _fieldProps_fieldName : '').trim();
102
+ const fieldName = formInputName(field);
92
103
  if (!fieldName || isMarketingConsentFieldName(fieldName)) continue;
93
- const label = String((_fieldProps_label = fieldProps['label']) != null ? _fieldProps_label : '').trim();
94
- const fieldType = String((_fieldProps_fieldType = fieldProps['fieldType']) != null ? _fieldProps_fieldType : 'text');
104
+ if (byName.has(fieldName)) {
105
+ const already = byName.get(fieldName);
106
+ if (already) fieldProps['datasetFieldId'] = already;
107
+ continue;
108
+ }
109
+ const label = (_labels_get = labels.get(fieldName)) != null ? _labels_get : '';
110
+ const fieldType = formInputType(field);
95
111
  // Its name first, then its label: `fullName` is "Full name" before "Your name" is.
96
112
  const matching = (key)=>key ? dataset.fields.find((one)=>!taken.has(one.id) && aiFormFieldKey(one.name) === key && aiFormFieldFitsDataset(fieldType, one.type)) : undefined;
97
113
  const match = (_matching = matching(aiFormFieldKey(fieldName))) != null ? _matching : matching(aiFormFieldKey(label));
98
114
  if (!match) {
115
+ byName.set(fieldName, null);
99
116
  result.unbound.push(label || fieldName);
100
117
  continue;
101
118
  }
102
119
  taken.add(match.id);
120
+ byName.set(fieldName, match.id);
103
121
  fieldProps['datasetFieldId'] = match.id;
104
122
  result.bound.push(fieldName);
105
123
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-job-form-dataset.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 { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n collectFormFieldNodeIds,\n FORM_COMPONENT_ID,\n isMarketingConsentFieldName,\n} from '@aglyn/aglyn/app-utils/forms'\nimport { pluginResourceDraftWriter } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\nimport type { NodesMap } from '@aglyn/aglyn/types/nodes'\nimport type { AiSiteInventory } from '../model/ai-site-inventory'\nimport { AI_DATASET_DRAFT_RESOURCE } from './ai-build-unit-outcome'\n\n/**\n * A FORM THAT WRITES TO A DATASET (AGL-3616; Zach, 2026-10-10: Aglyn AI's\n * forms can write to datasets — a volunteer sign-up into a Volunteers\n * dataset, an RSVP into an event's attendees, a catering inquiry into\n * Inquiries).\n *\n * The plan says which dataset a form writes to (`writesTo` on its creation);\n * it is never inferred from what a section places. The dataset is built\n * first — a site start's dataset unit, a build's dataset item, or one the\n * site already has — and the form step binds the form it generated the way a\n * person binds one on the Form element: the form node's `datasetId`, and\n * each Form Field's `datasetFieldId` where the dataset has a field of the\n * same name and a type the submitted value can be stored as. A field the\n * dataset lacks is left unbound, as the Form element's mapping leaves one:\n * its value stays in the Inbox copy, and a form never adds a field to a\n * dataset.\n *\n * The gates are the platform's. The binding is made only where the plan\n * includes the data store (`dataStore`), the data plugin's writer is loaded\n * here and reads the dataset, and the dataset is one this job made or one the\n * site's inventory shows. Records, their allowance and storage are held at\n * each submission by the data plugin's own record target. Anywhere else the\n * form is the form it always was, its submissions in the Inbox.\n */\n\n/** The unit input a dataset this job made for the form rides in, by id. */\nexport const AI_FORM_DATASET_MADE_INPUT = 'formDatasetMade'\n\n/** One field of the dataset a form writes to, as its writer reports it: storage type words. */\nexport interface AiFormDatasetField {\n id: string\n name: string\n type: string\n}\n\n/** The dataset a form writes to, with the fields a submission may fill. */\nexport interface AiFormDataset {\n id: string\n name: string\n fields: AiFormDatasetField[]\n}\n\n/** A field name as two spellings of it compare: `fullName`, `full_name` and \"Full name\" are one. */\nexport function aiFormFieldKey(name: string): string {\n return name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '')\n}\n\nconst NUMBER_TYPES = new Set(['float', 'int32', 'int64', 'number', 'integer'])\n\n/**\n * Whether a submitted value of this form field type can be stored in a\n * dataset field of this storage type. Every value a form submits is text, so\n * a text field takes any of them and a number field takes a rating; any\n * other pairing is left unbound rather than refused at every submission.\n */\nexport function aiFormFieldFitsDataset(fieldType: string, datasetType: string): boolean {\n if (datasetType === 'text') return true\n if (NUMBER_TYPES.has(datasetType)) return fieldType === 'rating'\n return false\n}\n\n/** What a binding came to: the fields bound, and the fields the dataset has no place for. */\nexport interface AiFormDatasetBinding {\n bound: string[]\n unbound: string[]\n}\n\n/**\n * Binds a form design to a dataset, in place: the form node's `datasetId`,\n * and `datasetFieldId` on each Form Field the dataset has a field for, by its\n * `fieldName` or its label, each dataset field taken once. The platform's\n * consent field is never bound. A binding the design already carried is\n * replaced, so what the model drew never chooses where a record lands.\n */\nexport function aiBindFormToDataset(\n nodes: NodesMap,\n formNodeId: string,\n dataset: AiFormDataset,\n): AiFormDatasetBinding {\n const result: AiFormDatasetBinding = { bound: [], unbound: [] }\n const form = nodes[formNodeId]\n if (form?.componentId !== FORM_COMPONENT_ID) return result\n const props = { ...((form.props ?? {}) as Record<string, unknown>) }\n delete props['datasetName']\n props['datasetId'] = dataset.id\n form.props = props as never\n const taken = new Set<string>()\n for (const id of collectFormFieldNodeIds(nodes as never, formNodeId)) {\n const field = nodes[id]\n if (!field) continue\n const fieldProps = { ...((field.props ?? {}) as Record<string, unknown>) }\n delete fieldProps['datasetFieldId']\n field.props = fieldProps as never\n const fieldName = String(fieldProps['fieldName'] ?? '').trim()\n if (!fieldName || isMarketingConsentFieldName(fieldName)) continue\n const label = String(fieldProps['label'] ?? '').trim()\n const fieldType = String(fieldProps['fieldType'] ?? 'text')\n // Its name first, then its label: `fullName` is \"Full name\" before \"Your name\" is.\n const matching = (key: string) =>\n key\n ? dataset.fields.find((one) => !taken.has(one.id) && aiFormFieldKey(one.name) === key && aiFormFieldFitsDataset(fieldType, one.type))\n : undefined\n const match = matching(aiFormFieldKey(fieldName)) ?? matching(aiFormFieldKey(label))\n if (!match) {\n result.unbound.push(label || fieldName)\n continue\n }\n taken.add(match.id)\n fieldProps['datasetFieldId'] = match.id\n result.bound.push(fieldName)\n }\n return result\n}\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\nexport interface AiFormDatasetReadDeps {\n writerFor?: typeof pluginResourceDraftWriter\n}\n\n/**\n * The dataset a form may write to, or `null` where it may not: the plan\n * includes no data store, the data plugin's writer is not loaded here, the\n * dataset is neither one this job made nor one the site's inventory shows,\n * or it has no field a submission could fill.\n */\nexport async function aiReadFormDataset(\n input: {\n hostId: string\n id: string | null | undefined\n org: object | null\n inventory: Pick<AiSiteInventory, 'datasets'> | null\n /** The dataset this job made for the form, by id. */\n madeId?: unknown\n },\n deps: AiFormDatasetReadDeps = {},\n): Promise<AiFormDataset | null> {\n const id = str(input.id)\n if (!id) return null\n if (!checkEntitlement(input.org as never, 'dataStore')) return null\n const known = id === str(input.madeId) || (input.inventory?.datasets ?? []).some((row) => row.id === id)\n if (!known) return null\n const keeper = (deps.writerFor ?? pluginResourceDraftWriter)(AI_DATASET_DRAFT_RESOURCE)\n if (!keeper) return null\n const record = await keeper.writer.read({ hostId: input.hostId, id }).catch((error: unknown) => {\n console.error('ai form: the dataset it writes to could not be read', { hostId: input.hostId, id, error })\n return null\n })\n if (!record) return null\n const facts = (record.facts ?? {}) as { fields?: unknown; addressField?: unknown }\n const address = str(facts.addressField)\n const fields = (Array.isArray(facts.fields) ? (facts.fields as unknown[]) : [])\n .map((raw) => (raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}))\n .map((raw) => ({ id: str(raw['id']), name: str(raw['name']), type: str(raw['type']) || 'text' }))\n .filter((field) => field.id && field.name && field.id !== address)\n if (!fields.length) return null\n return { id, name: str(record.name) || 'dataset', fields }\n}\n\n/** What the form's generation is told about the dataset it writes to. */\nexport function aiFormDatasetPromptLine(dataset: AiFormDataset): string {\n const fields = dataset.fields.map((field) => `${field.name} (${NUMBER_TYPES.has(field.type) ? 'a number' : 'text'})`)\n return `Each submission is also saved as a record of the dataset “${dataset.name}”, whose fields are: ${fields.join(', ')}. Draw a field for each one a visitor can answer, labeled with the field's name.`\n}\n\n/** What the form's row says about where its submissions are also saved. */\nexport function aiFormDatasetNote(dataset: AiFormDataset, binding: AiFormDatasetBinding): string {\n const kept = `Each submission is also saved as a record of the “${dataset.name}” dataset, in Data.`\n if (!binding.unbound.length) return kept\n const one = binding.unbound.length === 1\n return `${kept} The dataset has no field for ${binding.unbound.map((name) => `“${name}”`).join(', ')}, so ${\n one ? 'that answer stays' : 'those answers stay'\n } in the Inbox only.`\n}\n"],"names":["checkEntitlement","collectFormFieldNodeIds","FORM_COMPONENT_ID","isMarketingConsentFieldName","pluginResourceDraftWriter","AI_DATASET_DRAFT_RESOURCE","AI_FORM_DATASET_MADE_INPUT","aiFormFieldKey","name","replace","toLowerCase","NUMBER_TYPES","Set","aiFormFieldFitsDataset","fieldType","datasetType","has","aiBindFormToDataset","nodes","formNodeId","dataset","form","result","bound","unbound","componentId","props","id","taken","field","fieldProps","matching","fieldName","String","trim","label","key","fields","find","one","type","undefined","match","push","add","str","value","aiReadFormDataset","input","deps","record","org","known","madeId","inventory","datasets","some","row","keeper","writerFor","writer","read","hostId","catch","error","console","facts","address","addressField","Array","isArray","map","raw","filter","length","aiFormDatasetPromptLine","join","aiFormDatasetNote","binding","kept"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/jobs/ai-job-form-dataset.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 { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport {\n collectFormFieldNodeIds,\n FORM_COMPONENT_ID,\n formFieldDeclsFromNodes,\n formInputName,\n formInputType,\n isMarketingConsentFieldName,\n} from '@aglyn/aglyn/app-utils/forms'\nimport { pluginResourceDraftWriter } from '@aglyn/aglyn/plugin-manager/plugin-resource-drafts'\nimport type { NodesMap } from '@aglyn/aglyn/types/nodes'\nimport type { AiSiteInventory } from '../model/ai-site-inventory'\nimport { AI_DATASET_DRAFT_RESOURCE } from './ai-build-unit-outcome'\n\n/**\n * A FORM THAT WRITES TO A DATASET (AGL-3616; Zach, 2026-10-10: Aglyn AI's\n * forms can write to datasets — a volunteer sign-up into a Volunteers\n * dataset, an RSVP into an event's attendees, a catering inquiry into\n * Inquiries).\n *\n * The plan says which dataset a form writes to (`writesTo` on its creation);\n * it is never inferred from what a section places. The dataset is built\n * first — a site start's dataset unit, a build's dataset item, or one the\n * site already has — and the form step binds the form it generated the way a\n * person binds one on the Form element: the form node's `datasetId`, and\n * each answer element's `datasetFieldId` where the dataset has a field of the\n * same name and a type the submitted value can be stored as. A field the\n * dataset lacks is left unbound, as the Form element's mapping leaves one:\n * its value stays in the Inbox copy, and a form never adds a field to a\n * dataset.\n *\n * The gates are the platform's. The binding is made only where the plan\n * includes the data store (`dataStore`), the data plugin's writer is loaded\n * here and reads the dataset, and the dataset is one this job made or one the\n * site's inventory shows. Records, their allowance and storage are held at\n * each submission by the data plugin's own record target. Anywhere else the\n * form is the form it always was, its submissions in the Inbox.\n */\n\n/** The unit input a dataset this job made for the form rides in, by id. */\nexport const AI_FORM_DATASET_MADE_INPUT = 'formDatasetMade'\n\n/** One field of the dataset a form writes to, as its writer reports it: storage type words. */\nexport interface AiFormDatasetField {\n id: string\n name: string\n type: string\n}\n\n/** The dataset a form writes to, with the fields a submission may fill. */\nexport interface AiFormDataset {\n id: string\n name: string\n fields: AiFormDatasetField[]\n}\n\n/** A field name as two spellings of it compare: `fullName`, `full_name` and \"Full name\" are one. */\nexport function aiFormFieldKey(name: string): string {\n return name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '')\n}\n\nconst NUMBER_TYPES = new Set(['float', 'int32', 'int64', 'number', 'integer'])\n\n/**\n * Whether a submitted value of this form field type can be stored in a\n * dataset field of this storage type. Every value a form submits is text, so\n * a text field takes any of them and a number field takes a rating; any\n * other pairing is left unbound rather than refused at every submission.\n */\nexport function aiFormFieldFitsDataset(fieldType: string, datasetType: string): boolean {\n if (datasetType === 'text') return true\n if (NUMBER_TYPES.has(datasetType)) return fieldType === 'rating'\n return false\n}\n\n/** What a binding came to: the fields bound, and the fields the dataset has no place for. */\nexport interface AiFormDatasetBinding {\n bound: string[]\n unbound: string[]\n}\n\n/**\n * Binds a form design to a dataset, in place: the form node's `datasetId`,\n * and `datasetFieldId` on each answer element (an Input, Select, Radio Group,\n * Checkbox or Rating) the dataset has a field for, by the name it submits\n * under or its label — the Form Label or Choice Label beside it — each\n * dataset field taken once. Checkboxes sharing a name are one answer and are\n * bound together. The platform's consent field is never bound. A binding the\n * design already carried is replaced, so what the model drew never chooses\n * where a record lands.\n */\nexport function aiBindFormToDataset(\n nodes: NodesMap,\n formNodeId: string,\n dataset: AiFormDataset,\n): AiFormDatasetBinding {\n const result: AiFormDatasetBinding = { bound: [], unbound: [] }\n const form = nodes[formNodeId]\n if (form?.componentId !== FORM_COMPONENT_ID) return result\n const props = { ...((form.props ?? {}) as Record<string, unknown>) }\n delete props['datasetName']\n props['datasetId'] = dataset.id\n form.props = props as never\n const labels = new Map(\n formFieldDeclsFromNodes(nodes as never, formNodeId).map((decl) => [decl.fieldName, decl.label ?? ''] as const),\n )\n const taken = new Set<string>()\n /** The dataset field each answer's name was bound to, or `null` where none was. */\n const byName = new Map<string, string | null>()\n for (const id of collectFormFieldNodeIds(nodes as never, formNodeId)) {\n const field = nodes[id]\n if (!field) continue\n const fieldProps = { ...((field.props ?? {}) as Record<string, unknown>) }\n delete fieldProps['datasetFieldId']\n field.props = fieldProps as never\n const fieldName = formInputName(field as never)\n if (!fieldName || isMarketingConsentFieldName(fieldName)) continue\n if (byName.has(fieldName)) {\n const already = byName.get(fieldName)\n if (already) fieldProps['datasetFieldId'] = already\n continue\n }\n const label = labels.get(fieldName) ?? ''\n const fieldType = formInputType(field as never)\n // Its name first, then its label: `fullName` is \"Full name\" before \"Your name\" is.\n const matching = (key: string) =>\n key\n ? dataset.fields.find((one) => !taken.has(one.id) && aiFormFieldKey(one.name) === key && aiFormFieldFitsDataset(fieldType, one.type))\n : undefined\n const match = matching(aiFormFieldKey(fieldName)) ?? matching(aiFormFieldKey(label))\n if (!match) {\n byName.set(fieldName, null)\n result.unbound.push(label || fieldName)\n continue\n }\n taken.add(match.id)\n byName.set(fieldName, match.id)\n fieldProps['datasetFieldId'] = match.id\n result.bound.push(fieldName)\n }\n return result\n}\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\nexport interface AiFormDatasetReadDeps {\n writerFor?: typeof pluginResourceDraftWriter\n}\n\n/**\n * The dataset a form may write to, or `null` where it may not: the plan\n * includes no data store, the data plugin's writer is not loaded here, the\n * dataset is neither one this job made nor one the site's inventory shows,\n * or it has no field a submission could fill.\n */\nexport async function aiReadFormDataset(\n input: {\n hostId: string\n id: string | null | undefined\n org: object | null\n inventory: Pick<AiSiteInventory, 'datasets'> | null\n /** The dataset this job made for the form, by id. */\n madeId?: unknown\n },\n deps: AiFormDatasetReadDeps = {},\n): Promise<AiFormDataset | null> {\n const id = str(input.id)\n if (!id) return null\n if (!checkEntitlement(input.org as never, 'dataStore')) return null\n const known = id === str(input.madeId) || (input.inventory?.datasets ?? []).some((row) => row.id === id)\n if (!known) return null\n const keeper = (deps.writerFor ?? pluginResourceDraftWriter)(AI_DATASET_DRAFT_RESOURCE)\n if (!keeper) return null\n const record = await keeper.writer.read({ hostId: input.hostId, id }).catch((error: unknown) => {\n console.error('ai form: the dataset it writes to could not be read', { hostId: input.hostId, id, error })\n return null\n })\n if (!record) return null\n const facts = (record.facts ?? {}) as { fields?: unknown; addressField?: unknown }\n const address = str(facts.addressField)\n const fields = (Array.isArray(facts.fields) ? (facts.fields as unknown[]) : [])\n .map((raw) => (raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}))\n .map((raw) => ({ id: str(raw['id']), name: str(raw['name']), type: str(raw['type']) || 'text' }))\n .filter((field) => field.id && field.name && field.id !== address)\n if (!fields.length) return null\n return { id, name: str(record.name) || 'dataset', fields }\n}\n\n/** What the form's generation is told about the dataset it writes to. */\nexport function aiFormDatasetPromptLine(dataset: AiFormDataset): string {\n const fields = dataset.fields.map((field) => `${field.name} (${NUMBER_TYPES.has(field.type) ? 'a number' : 'text'})`)\n return `Each submission is also saved as a record of the dataset “${dataset.name}”, whose fields are: ${fields.join(', ')}. Draw a field for each one a visitor can answer, labeled with the field's name.`\n}\n\n/** What the form's row says about where its submissions are also saved. */\nexport function aiFormDatasetNote(dataset: AiFormDataset, binding: AiFormDatasetBinding): string {\n const kept = `Each submission is also saved as a record of the “${dataset.name}” dataset, in Data.`\n if (!binding.unbound.length) return kept\n const one = binding.unbound.length === 1\n return `${kept} The dataset has no field for ${binding.unbound.map((name) => `“${name}”`).join(', ')}, so ${\n one ? 'that answer stays' : 'those answers stay'\n } in the Inbox only.`\n}\n"],"names":["checkEntitlement","collectFormFieldNodeIds","FORM_COMPONENT_ID","formFieldDeclsFromNodes","formInputName","formInputType","isMarketingConsentFieldName","pluginResourceDraftWriter","AI_DATASET_DRAFT_RESOURCE","AI_FORM_DATASET_MADE_INPUT","aiFormFieldKey","name","replace","toLowerCase","NUMBER_TYPES","Set","aiFormFieldFitsDataset","fieldType","datasetType","has","aiBindFormToDataset","nodes","formNodeId","dataset","form","result","bound","unbound","componentId","props","id","labels","Map","map","decl","fieldName","label","taken","byName","field","matching","fieldProps","already","get","key","fields","find","one","type","undefined","match","set","push","add","str","value","trim","aiReadFormDataset","input","deps","record","org","known","madeId","inventory","datasets","some","row","keeper","writerFor","writer","read","hostId","catch","error","console","facts","address","addressField","Array","isArray","raw","filter","length","aiFormDatasetPromptLine","join","aiFormDatasetNote","binding","kept"],"mappings":"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"}
@@ -40,9 +40,9 @@ import type { AiJobStepRunner } from './ai-job-text-step';
40
40
  *
41
41
  * - `fields` is `formFieldDeclsFromNodes` over the design;
42
42
  * - a form that can yield an email address gets the platform's marketing
43
- * consent field (`MARKETING_CONSENT_FORM_FIELD`, the Forms editor preset's own
44
- * props) in place of any consent-like field the model drew, named as its
45
- * `consentFieldName`;
43
+ * consent field (`aiFormConsentNodes`: a Choice Label holding one Checkbox,
44
+ * worded as the Forms editor preset's option) in place of any consent-like
45
+ * field the model drew, named as its `consentFieldName`;
46
46
  * - `routing` is `{ lead: true }` when the answer proposes a lead, and
47
47
  * nothing otherwise. The stored routing has no place for an email list,
48
48
  * so a list proposal leaves the form on the Inbox and says, in the output's
@@ -91,8 +91,24 @@ export declare const AI_FORM_ROUTING_KINDS: readonly ["inbox", "list", "lead"];
91
91
  export type AiFormRoutingKind = (typeof AI_FORM_ROUTING_KINDS)[number];
92
92
  /** The most things a note says the brief asked for and a form cannot collect. */
93
93
  export declare const AI_FORM_CANNOT_COLLECT_MAX = 3;
94
- /** The node id the platform's consent field takes in a generated design. */
94
+ /**
95
+ * The node id the platform's consent field takes in a generated design: the
96
+ * Choice Label that holds its Checkbox.
97
+ */
95
98
  export declare const AI_FORM_CONSENT_NODE_ID = "marketingConsentField";
99
+ /** The node id of the consent field's Checkbox, inside {@link AI_FORM_CONSENT_NODE_ID}. */
100
+ export declare const AI_FORM_CONSENT_CHECKBOX_NODE_ID = "marketingConsentCheckbox";
101
+ /** The name the consent Checkbox submits under, and the form's `consentFieldName`. */
102
+ export declare const AI_FORM_CONSENT_FIELD_NAME: string;
103
+ /** What a ticked consent Checkbox posts: an affirmative value `isConsentCheckboxTicked` reads as the tick. */
104
+ export declare const AI_FORM_CONSENT_VALUE = "Yes";
105
+ /**
106
+ * The platform's marketing consent field as MUI's separate elements: a Choice
107
+ * Label whose words say what the person agrees to — the Forms editor preset's
108
+ * own option text — holding one unticked, unrequired Checkbox named
109
+ * `marketingConsent`. Ids are fixed, so a re-asked design gets the same nodes.
110
+ */
111
+ export declare function aiFormConsentNodes(formNodeId: string): NodesMap[string][];
96
112
  /** The form step's own instructions, cached after the doctrine. */
97
113
  export declare const AI_JOB_FORM_INSTRUCTIONS: readonly AiSystemBlock[];
98
114
  /**