@vectoriox/iox-ui 5.54.0 → 5.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+
3
+ import {
4
+ jsonAnyOf, jsonBoolean, jsonClosedObject, jsonInteger, jsonListOf, jsonOneOf, JsonSchema, jsonShapedObject, jsonText,
5
+ } from './json-schema';
6
+ import { patternOptionalParts, patternSlots, patternTakesBinding, PatternGlobalPosition, SITE_PATTERNS, SitePattern } from './patterns';
7
+ import {
8
+ FACT_KEYS, GlobalSlot, PlanDataSourceMode, PlanDataSourceType, RelativeOp, SitePlan, SitePlanSource, SitePlanSourceKind, SITE_PLAN_VERSION,
9
+ } from './site-plan.model';
10
+ import { SPEC_SITE_PLAN_SHAPE } from './site-spec.generated';
11
+ import { isPlainObject, PlainObject, ShapeEntry } from './shape-check';
12
+
13
+ /**
14
+ * The structured-output format a model writes a Site Plan in (AI plan §3d step 4, M1-3) — built from the SAME shape
15
+ * table `validateSitePlan` reads (`SPEC_SITE_PLAN_SHAPE`) and the SAME pattern library, so the plan a model is told
16
+ * about is the plan the gate refuses. ONE source: a key the table gains breaks this builder until it is written here.
17
+ *
18
+ * Two maps of the shape are written as LISTS on the wire, because no strict mode can say "any key": a section's
19
+ * `slots` (`[{ slot, text }]`, the slot names closed per pattern) and a seed's field values (`values: [{ field,
20
+ * value }]`). `sitePlanFromWire` turns them back; nothing else differs. Every section is one `anyOf` branch per
21
+ * pattern — its id, its declared slots, its optional parts, a binding only when it takes one — and the design, the
22
+ * source and the version are PINNED to the owner's pick and this run (one-value enums), so the provider cannot let
23
+ * a model change the look. The gate still checks both (`plan.design` deep-equals the pick).
24
+ */
25
+
26
+ export const SITE_PLAN_SCHEMA_NAME = 'site_plan';
27
+
28
+ /** The wire keys of the two maps written as lists. */
29
+ export enum SitePlanWireKey {
30
+ SlotName = 'slot',
31
+ SlotText = 'text',
32
+ SeedValues = 'values',
33
+ SeedField = 'field',
34
+ SeedValue = 'value',
35
+ }
36
+
37
+ const SHAPE: Readonly<Record<string, ShapeEntry>> = SPEC_SITE_PLAN_SHAPE;
38
+
39
+ export interface SitePlanSchemaPin {
40
+ /** The owner's pick, verbatim — every design key becomes a one-value enum. */
41
+ design: DesignChoices;
42
+ /** This generation. */
43
+ source: SitePlanSource;
44
+ }
45
+
46
+ const one = (value: string): JsonSchema => jsonOneOf([value]);
47
+ const oneBoolean = (value: boolean): JsonSchema => ({ type: 'boolean', enum: [value] });
48
+
49
+ /** The pick, key by key, as one-value enums; an optional key the pick leaves out may only be left out (or empty). */
50
+ function pinnedDesign(design: DesignChoices): JsonSchema {
51
+ const entry = SHAPE['design'];
52
+ const values = design as unknown as Record<string, unknown>;
53
+ const properties: Record<string, JsonSchema> = {};
54
+ for (const key of Object.keys(entry).map(k => k.replace(/\?$/, ''))) {
55
+ const v = values[key];
56
+ if (typeof v === 'string') properties[key] = one(v);
57
+ else if (typeof v === 'boolean') properties[key] = oneBoolean(v);
58
+ else if (isPlainObject(v)) properties[key] = jsonClosedObject(
59
+ Object.fromEntries(Object.entries(v).map(([role, family]) => [role, one(String(family))])), Object.keys(v));
60
+ else properties[key] = jsonClosedObject({}, []);
61
+ }
62
+ const shaped = jsonShapedObject(entry, properties, 'design');
63
+ // Everything the pick carries is required: a model may not leave a choice out and let a default stand in.
64
+ const given = Object.keys(values).filter(k => values[k] !== undefined && k in properties);
65
+ return { ...shaped, required: [...new Set([...(shaped['required'] as string[]), ...given])] };
66
+ }
67
+
68
+ function sourceSchema(source: SitePlanSource): JsonSchema {
69
+ if (source.kind === SitePlanSourceKind.Ai) return jsonShapedObject(SHAPE['sourceAi'], { kind: one(source.kind), runId: one(source.runId) }, 'sourceAi');
70
+ return jsonShapedObject(SHAPE['sourceTemplate'], {
71
+ kind: one(source.kind), templateId: one(source.templateId), templateVersion: { type: 'integer', enum: [source.templateVersion] },
72
+ }, 'sourceTemplate');
73
+ }
74
+
75
+ function businessSchema(): JsonSchema {
76
+ const facts = jsonClosedObject(Object.fromEntries(FACT_KEYS.map(key => [key, jsonText()])), []);
77
+ return jsonShapedObject(SHAPE['business'], { name: jsonText(), tagline: jsonText(), facts }, 'business');
78
+ }
79
+
80
+ /** A content type's field. */
81
+ export function fieldJsonSchema(): JsonSchema {
82
+ return jsonShapedObject(SHAPE['field'], { name: jsonText(), type: jsonText(), controller: jsonText(), required: jsonBoolean(), note: jsonText() }, 'field');
83
+ }
84
+
85
+ /** A content type, its seed's field values as a list (`values: [{ field, value }]`). */
86
+ export function contentTypeJsonSchema(): JsonSchema {
87
+ const value = jsonAnyOf([jsonText(), jsonListOf(jsonText())]);
88
+ const values = jsonListOf(jsonClosedObject({ [SitePlanWireKey.SeedField]: jsonText(), [SitePlanWireKey.SeedValue]: value }, [SitePlanWireKey.SeedField, SitePlanWireKey.SeedValue]));
89
+ const seed = jsonShapedObject(SHAPE['seed'], { count: jsonInteger(), sample: jsonBoolean(), isPublished: jsonBoolean() }, 'seed',
90
+ { key: SitePlanWireKey.SeedValues, schema: values });
91
+ return jsonShapedObject(SHAPE['contentType'], {
92
+ name: jsonText(), label: jsonText(), previewTextField: jsonText(), previewImageField: jsonText(), fields: jsonListOf(fieldJsonSchema()), seed,
93
+ }, 'contentType');
94
+ }
95
+
96
+ /** A data source: a cms list / single item, or a relative one. */
97
+ export function dataSourceJsonSchema(): JsonSchema {
98
+ const byRoute = jsonClosedObject({ field: jsonText(), routeParam: jsonText() }, ['field', 'routeParam']);
99
+ const cms = jsonShapedObject(SHAPE['cmsDataSource'], {
100
+ alias: jsonText(), type: one(PlanDataSourceType.Cms), mode: jsonOneOf(Object.values(PlanDataSourceMode)),
101
+ contentType: jsonText(), limit: jsonInteger(), filterByRouteParam: byRoute,
102
+ }, 'cmsDataSource');
103
+ const relative = jsonShapedObject(SHAPE['relativeDataSource'], {
104
+ alias: jsonText(), type: one(PlanDataSourceType.Relative), of: jsonText(), op: jsonOneOf(Object.values(RelativeOp)), within: jsonText(),
105
+ }, 'relativeDataSource');
106
+ return jsonAnyOf([cms, relative]);
107
+ }
108
+
109
+ /** The patterns a page section may use (every non-global one), in library order. */
110
+ export function sectionPatternIds(): string[] {
111
+ return Object.entries(SITE_PATTERNS).filter(([, p]) => !p.global).map(([id]) => id);
112
+ }
113
+
114
+ /** A key a pattern has nothing for: only `{}` (or leaving it out) — the decoder drops it. */
115
+ const NOTHING = (): JsonSchema => jsonClosedObject({}, []);
116
+
117
+ /** One section branch: this pattern's id, its declared slots (a list), its optional parts, a binding when it takes one. */
118
+ function sectionBranch(id: string, pattern: SitePattern): JsonSchema {
119
+ const slots = patternSlots(pattern);
120
+ const parts = patternOptionalParts(pattern);
121
+ const slotList = slots.length
122
+ ? jsonListOf(jsonClosedObject({ [SitePlanWireKey.SlotName]: jsonOneOf(slots), [SitePlanWireKey.SlotText]: jsonText() }, [SitePlanWireKey.SlotName, SitePlanWireKey.SlotText]))
123
+ : NOTHING();
124
+ const branch = jsonShapedObject(SHAPE['section'], {
125
+ pattern: one(id),
126
+ slots: slotList,
127
+ binding: patternTakesBinding(pattern) ? jsonText() : NOTHING(),
128
+ without: parts.length ? jsonListOf(jsonOneOf(parts)) : NOTHING(),
129
+ }, 'section');
130
+ return { ...branch, description: `${id}: ${pattern.label}` };
131
+ }
132
+
133
+ /** A page section: one branch per pattern. */
134
+ export function sectionJsonSchema(): JsonSchema {
135
+ return jsonAnyOf(sectionPatternIds().map(id => sectionBranch(id, SITE_PATTERNS[id])));
136
+ }
137
+
138
+ /** A page. */
139
+ export function pageJsonSchema(): JsonSchema {
140
+ return jsonShapedObject(SHAPE['page'], {
141
+ key: jsonText(), name: jsonText(), route: jsonText(), isHome: jsonBoolean(), required: jsonBoolean(), inNav: jsonBoolean(),
142
+ template: jsonText(), routeSource: jsonText(), sections: jsonListOf(sectionJsonSchema()),
143
+ }, 'page');
144
+ }
145
+
146
+ const globalIds = (position: `${PatternGlobalPosition}`) => Object.entries(SITE_PATTERNS).filter(([, p]) => p.global === position).map(([id]) => id);
147
+
148
+ /** The Site Plan format, pinned to the owner's pick and this run. */
149
+ export function sitePlanJsonSchema(pin: SitePlanSchemaPin): JsonSchema {
150
+ const globals = jsonShapedObject(SHAPE['globals'], {
151
+ [GlobalSlot.Header]: jsonOneOf(globalIds(PatternGlobalPosition.Before)),
152
+ [GlobalSlot.Footer]: jsonOneOf(globalIds(PatternGlobalPosition.After)),
153
+ }, 'globals');
154
+ return jsonShapedObject(SHAPE['plan'], {
155
+ version: one(SITE_PLAN_VERSION),
156
+ source: sourceSchema(pin.source),
157
+ business: businessSchema(),
158
+ design: pinnedDesign(pin.design),
159
+ contentTypes: jsonListOf(contentTypeJsonSchema()),
160
+ dataSources: jsonListOf(dataSourceJsonSchema()),
161
+ pages: jsonListOf(pageJsonSchema()),
162
+ globals,
163
+ }, 'plan');
164
+ }
165
+
166
+ // ── The wire → the plan ─────────────────────────────────────────────────────────────────────────────
167
+
168
+ const isEmptyObject = (v: unknown) => isPlainObject(v) && Object.keys(v).length === 0;
169
+
170
+ /** `[{ slot, text }]` → `{ slot: text }`; anything that is not that list is left for the gate to refuse. */
171
+ function slotsFromWire(slots: unknown): unknown {
172
+ if (!Array.isArray(slots)) return slots;
173
+ const out: PlainObject = {};
174
+ for (const entry of slots) {
175
+ if (!isPlainObject(entry) || typeof entry[SitePlanWireKey.SlotName] !== 'string') return slots;
176
+ out[entry[SitePlanWireKey.SlotName] as string] = entry[SitePlanWireKey.SlotText];
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /** A section off the wire: its slots back to a map; a key the pattern has nothing for (`{}`) dropped. */
182
+ export function sectionFromWire(section: unknown): unknown {
183
+ if (!isPlainObject(section)) return section;
184
+ const out: PlainObject = { ...section };
185
+ for (const key of ['slots', 'binding', 'without']) if (isEmptyObject(out[key])) delete out[key];
186
+ if ('slots' in out) out['slots'] = slotsFromWire(out['slots']);
187
+ return out;
188
+ }
189
+
190
+ /** A content type off the wire: its seed's `values` list back to field keys. */
191
+ export function contentTypeFromWire(type: unknown): unknown {
192
+ if (!isPlainObject(type) || !isPlainObject(type['seed'])) return type;
193
+ const { [SitePlanWireKey.SeedValues]: values, ...seed } = type['seed'] as PlainObject;
194
+ if (Array.isArray(values)) {
195
+ for (const entry of values) {
196
+ if (isPlainObject(entry) && typeof entry[SitePlanWireKey.SeedField] === 'string') seed[entry[SitePlanWireKey.SeedField] as string] = entry[SitePlanWireKey.SeedValue];
197
+ }
198
+ } else if (values !== undefined) seed[SitePlanWireKey.SeedValues] = values;
199
+ return { ...type, seed };
200
+ }
201
+
202
+ function pageFromWire(page: unknown): unknown {
203
+ if (!isPlainObject(page) || !Array.isArray(page['sections'])) return page;
204
+ return { ...page, sections: (page['sections'] as unknown[]).map(sectionFromWire) };
205
+ }
206
+
207
+ /** The design off the wire: an empty optional object (a choice the pick does not carry) dropped. */
208
+ function designFromWire(design: unknown): unknown {
209
+ if (!isPlainObject(design)) return design;
210
+ return Object.fromEntries(Object.entries(design).filter(([, v]) => !isEmptyObject(v)));
211
+ }
212
+
213
+ /**
214
+ * A model's answer → a Site Plan: the two listed maps back to maps, nothing else touched. Never repairs — whatever the
215
+ * answer got wrong is still wrong, for `validateSitePlan` to refuse with a path.
216
+ */
217
+ export function sitePlanFromWire(wire: unknown): SitePlan | unknown {
218
+ if (!isPlainObject(wire)) return wire;
219
+ const out: PlainObject = { ...wire };
220
+ if (Array.isArray(out['contentTypes'])) out['contentTypes'] = (out['contentTypes'] as unknown[]).map(contentTypeFromWire);
221
+ if (Array.isArray(out['pages'])) out['pages'] = (out['pages'] as unknown[]).map(pageFromWire);
222
+ if ('design' in out) out['design'] = designFromWire(out['design']);
223
+ return out;
224
+ }
225
+
226
+ /** The plan → the wire (what a re-ask shows the model, and what the plan edits' prompt carries). */
227
+ export function sitePlanToWire(plan: SitePlan): PlainObject {
228
+ const slotsToWire = (slots: Readonly<Record<string, string>> | undefined) =>
229
+ Object.entries(slots ?? {}).map(([slot, text]) => ({ [SitePlanWireKey.SlotName]: slot, [SitePlanWireKey.SlotText]: text }));
230
+ return {
231
+ ...plan,
232
+ contentTypes: plan.contentTypes.map(t => {
233
+ if (!t.seed) return t;
234
+ const { count, sample, isPublished, ...values } = t.seed;
235
+ return { ...t, seed: { count, sample, isPublished, [SitePlanWireKey.SeedValues]: Object.entries(values).map(([field, value]) => ({ [SitePlanWireKey.SeedField]: field, [SitePlanWireKey.SeedValue]: value })) } };
236
+ }),
237
+ pages: plan.pages.map(p => ({ ...p, sections: p.sections.map(s => (s.slots ? { ...s, slots: slotsToWire(s.slots) } : s)) })),
238
+ };
239
+ }
@@ -0,0 +1,137 @@
1
+ import { ComposedSite, PlaceholderKind } from './compose-site';
2
+ import { patternById } from './patterns';
3
+ import { createSerializedNode, SerializedNode, walkSerialized } from './serialized-node';
4
+ import { SitePlan } from './site-plan.model';
5
+
6
+ /**
7
+ * Stock photos in a built site (AI plan §3g / §3m, I2): a FREE picture from the platform's curated library, copied
8
+ * into the studio's own storage (never hot-linked), placed only in an EMPTY picture slot of a section that may show
9
+ * one — never the owner's work (a collection, a detail page, a gallery: what the nine portfolio types sell is theirs),
10
+ * never a person (testimonials, the team), never anywhere when the business type says `stock: false`. Its alt text is
11
+ * the provider's description of what it shows (never a claim), and the credit line its licence requires is placed in
12
+ * the footer. Pure: the build step does the copying.
13
+ */
14
+
15
+ /** Where a stock photo comes from (the library's provider). */
16
+ export enum StockProviderId {
17
+ Magnific = 'magnific',
18
+ }
19
+
20
+ /** What a picture's licence asks of the site that shows it. */
21
+ export interface StockCredit {
22
+ provider: `${StockProviderId}`;
23
+ /** The provider's id of the picture. */
24
+ resourceId: string;
25
+ /** The provider's page for it. */
26
+ sourceUrl: string;
27
+ /** The provider's own title — what the picture shows. */
28
+ title: string;
29
+ author: string;
30
+ /** `freemium` — the free licence (premium pictures never enter the library). */
31
+ licence: string;
32
+ licenceUrl: string;
33
+ /** The words the licence requires, and where they link. */
34
+ line: string;
35
+ lineUrl: string;
36
+ }
37
+
38
+ /** One picture of the platform library (`iox-stock/<type>/`), as its manifest lists it. */
39
+ export interface StockPhoto {
40
+ /** The library key (`spa/12345.jpg`) — the copy's file name is derived from it. */
41
+ key: string;
42
+ /** Where the build downloads it from (the platform bucket). */
43
+ url: string;
44
+ /** What it shows, in the provider's words — the alt text. Never a claim. */
45
+ alt: string;
46
+ width?: number;
47
+ height?: number;
48
+ credit: StockCredit;
49
+ }
50
+
51
+ /** The section families a stock photo may stand in for a picture in. */
52
+ export const STOCK_FAMILIES: ReadonlySet<string> = new Set(['hero', 'feature', 'content', 'story', 'cta', 'contact', 'header']);
53
+
54
+ export interface StockSlot {
55
+ /** The Image node the photo goes into. */
56
+ nodeId: string;
57
+ /** The page key it lives on. */
58
+ owner: string;
59
+ pattern: string;
60
+ }
61
+
62
+ /** The empty picture slots of a composed site a stock photo may fill, in page and section order. */
63
+ export function stockSlots(plan: SitePlan, composed: Pick<ComposedSite, 'pages' | 'placeholders'>): StockSlot[] {
64
+ const empty = new Set(composed.placeholders.filter(p => p.kind === PlaceholderKind.Image).map(p => p.nodeId));
65
+ const out: StockSlot[] = [];
66
+ composed.pages.forEach((page, p) => {
67
+ page.layout.forEach((root, s) => {
68
+ const pattern = plan.pages[p]?.sections[s]?.pattern;
69
+ const family = patternById(pattern)?.family;
70
+ if (!pattern || !family || !STOCK_FAMILIES.has(family)) return;
71
+ walkSerialized([root], n => {
72
+ if (n.type === 'Image' && empty.has(n.id) && !n.bindings.length) out.push({ nodeId: n.id, owner: page.key, pattern });
73
+ });
74
+ });
75
+ });
76
+ return out;
77
+ }
78
+
79
+ export interface StockPlacement extends StockSlot {
80
+ photo: StockPhoto;
81
+ }
82
+
83
+ /** Which photo goes where: the library's photos in order, one per slot, never twice; none when the type says no stock. */
84
+ export function planStockPhotos(slots: ReadonlyArray<StockSlot>, photos: ReadonlyArray<StockPhoto>, stockAllowed: boolean): StockPlacement[] {
85
+ if (!stockAllowed) return [];
86
+ return slots.slice(0, photos.length).map((slot, i) => ({ ...slot, photo: photos[i] }));
87
+ }
88
+
89
+ /** A placed photo's copy in the studio's storage: the key the build writes it under. */
90
+ export function stockCopyKey(photo: Pick<StockPhoto, 'key'>): string {
91
+ return `media/stock/${photo.key.replace(/[^a-zA-Z0-9._-]+/g, '-')}`;
92
+ }
93
+
94
+ /** Put the studio's copy of each photo into its slot (src + the provider's alt). Returns the nodes changed. */
95
+ export function applyStockPhotos(trees: ReadonlyArray<SerializedNode>, placed: ReadonlyArray<{ nodeId: string; url: string; alt: string }>): number {
96
+ const byId = new Map(placed.map(p => [p.nodeId, p]));
97
+ let changed = 0;
98
+ walkSerialized(trees, n => {
99
+ const p = byId.get(n.id);
100
+ if (!p || n.type !== 'Image') return;
101
+ n.props['src'] = p.url;
102
+ n.props['alt'] = p.alt;
103
+ changed++;
104
+ });
105
+ return changed;
106
+ }
107
+
108
+ /** The credit lines a set of photos needs — one per distinct line (a free Magnific photo: "Designed by Magnific"). */
109
+ export function stockCreditLines(photos: ReadonlyArray<Pick<StockPhoto, 'credit'>>): Array<{ line: string; url: string }> {
110
+ const seen = new Map<string, string>();
111
+ for (const p of photos) if (!seen.has(p.credit.line)) seen.set(p.credit.line, p.credit.lineUrl);
112
+ return [...seen.entries()].map(([line, url]) => ({ line, url }));
113
+ }
114
+
115
+ /** The footer node id of a credit line (stable — a build that runs twice finds it). */
116
+ export const stockCreditNodeId = (index: number) => `stock-credit-${index + 1}`;
117
+
118
+ /**
119
+ * The credit line, as the builder would create a Link (a plain node the owner may move or restyle): the licence's words,
120
+ * linking where it asks, opening in a new tab. Placed at the end of the footer — the place the licence names for a site.
121
+ */
122
+ export function stockCreditNode(index: number, credit: { line: string; url: string }): SerializedNode {
123
+ return createSerializedNode(stockCreditNodeId(index), 'Link', { label: credit.line, linkType: 'external', url: credit.url, target: '_blank' }, 'link');
124
+ }
125
+
126
+ /** Append the credit lines to the footer root (once each). Returns how many it added. */
127
+ export function addStockCredits(footer: SerializedNode, credits: ReadonlyArray<{ line: string; url: string }>): number {
128
+ footer.children ??= [];
129
+ let added = 0;
130
+ credits.forEach((credit, i) => {
131
+ const id = stockCreditNodeId(i);
132
+ if (footer.children!.some(c => c.id === id)) return;
133
+ footer.children!.push(stockCreditNode(i, credit));
134
+ added++;
135
+ });
136
+ return added;
137
+ }
@@ -0,0 +1,115 @@
1
+ import type { DesignChoices } from '@vectoriox/iox-ui/design-core';
2
+ import type { AiGenerationStage, AiStep, PlanChange, PlanEdit, SitePlan } from '@vectoriox/iox-ui/generation-core';
3
+
4
+ import type { StyleRead } from './rank';
5
+ import type { SiteAnalysis } from './site-analysis';
6
+ import type { PictureRole } from './site-reading';
7
+
8
+ /**
9
+ * A generation as the screens read it (AI plan "M1 pipeline — the screens contract"): what iox-cms's
10
+ * `GET …/ai/generations/:id` answers and the Thinking · Analysis · Design · Plan · Build screens render. ONE
11
+ * definition: iox-cms builds it, cms-client types its client with it. Types and enums only — no transport.
12
+ */
13
+
14
+ /** Where one reader is (Thinking's three lines). */
15
+ export enum ReaderState {
16
+ Wait = 'wait',
17
+ Run = 'run',
18
+ Done = 'done',
19
+ /** Nothing to read (no pictures, no address). */
20
+ Skipped = 'skipped',
21
+ /** Refused on purpose (robots.txt, a private address…) — said in one plain line, never blocks the reading. */
22
+ Refused = 'refused',
23
+ Failed = 'failed',
24
+ }
25
+
26
+ /** Why a website address was not read (the screens' i18n `ai.siteRead.<reason>`). */
27
+ export enum SiteReadRefusal {
28
+ /** robots.txt disallows our user agent (`IOX-Import`). */
29
+ Robots = 'robots',
30
+ InvalidAddress = 'invalid-address',
31
+ PrivateAddress = 'private-address',
32
+ Unreachable = 'unreachable',
33
+ Timeout = 'timeout',
34
+ TooLarge = 'too-large',
35
+ NotHtml = 'not-html',
36
+ HttpError = 'http-error',
37
+ TooManyRedirects = 'redirects',
38
+ }
39
+
40
+ /** What a sentence in the Plan prompt box did. */
41
+ export enum PlanChangeOutcome {
42
+ Applied = 'applied',
43
+ /** The closed operations cannot say it — one line, the map unchanged (the call still cost). */
44
+ Cannot = 'cannot',
45
+ /** Its edits did not make a valid plan, twice — the map unchanged. */
46
+ Invalid = 'invalid',
47
+ }
48
+
49
+ export interface GenerationFileView {
50
+ key: string;
51
+ url: string;
52
+ role: `${PictureRole}`;
53
+ }
54
+
55
+ export interface SiteReaderView {
56
+ address: string;
57
+ state: `${ReaderState}`;
58
+ reason?: `${SiteReadRefusal}`;
59
+ }
60
+
61
+ export interface GenerationReadersView {
62
+ words: { state: `${ReaderState}` };
63
+ pictures: { state: `${ReaderState}`; count: number };
64
+ site: { state: `${ReaderState}`; addresses: SiteReaderView[] };
65
+ }
66
+
67
+ export interface PlanRevisionView {
68
+ plan: SitePlan;
69
+ at: string;
70
+ sentence?: string;
71
+ edits?: PlanEdit[];
72
+ changed?: PlanChange[];
73
+ }
74
+
75
+ export interface AiGenerationView {
76
+ _id: string;
77
+ organization: string;
78
+ stage: `${AiGenerationStage}`;
79
+ /** The model step the runner is working on; null = the owner's move. */
80
+ task: `${AiStep}` | null;
81
+ intake: { prompt: string; files: GenerationFileView[]; addresses: string[]; auto: boolean };
82
+ readers: GenerationReadersView;
83
+ reads: { analysis?: { value: SiteAnalysis; model: string; usageIds: string[] }; style?: StyleRead | null };
84
+ /** What Design and Plan use: the owner's edit, else the reading. */
85
+ analysis: SiteAnalysis | null;
86
+ analysisRevision: number;
87
+ design?: { presetId?: string; design: DesignChoices; auto?: boolean };
88
+ plan?: {
89
+ revisions: PlanRevisionView[];
90
+ model: string;
91
+ lastChange?: { sentence: string; outcome: `${PlanChangeOutcome}`; message?: string; at: string };
92
+ };
93
+ siteBuildId?: string;
94
+ models: Partial<Record<`${AiStep}`, string>>;
95
+ estimate: number;
96
+ cap: number;
97
+ /** Derived from the ledger — the running credit total. */
98
+ spent: number;
99
+ error?: { code: string; detail: string; providerMessage?: string; issues?: Array<{ code: string; path: string }> };
100
+ }
101
+
102
+ /** `POST …/ai/generations`. */
103
+ export interface GenerationStartRequest {
104
+ prompt: string;
105
+ files?: Array<{ key: string; role?: `${PictureRole}` }>;
106
+ addresses?: string[];
107
+ }
108
+
109
+ /** The longest message, the most files and addresses a generation starts from (Start's limits). */
110
+ export const GENERATION_LIMITS = {
111
+ promptChars: 4_000,
112
+ files: 12,
113
+ addresses: 3,
114
+ sentenceChars: 500,
115
+ } as const;